{
  "name": "GEO Share of Voice Monitor - Full Weekly Run with Email Report",
  "nodes": [
    {
      "parameters": {
        "content": "## GEO Share of Voice Monitor for AI Search\n\nMeasures weekly how visible a brand is in the answers of ChatGPT, Perplexity, Gemini and Claude, and sends an HTML visibility report by email.\n\n**Who it's for:** SEO/GEO teams, agencies and brands that want to track Share of Voice, brand mentions and domain citations in AI answers without buying a SaaS tool.\n\n**How it works:** Every tracked prompt runs multiple times against four AI models (one single API account via OpenRouter). Brand mentions and domain citations are detected deterministically via regex, aggregated per week into Share of Voice, Mention Rate and Citation Rate, and delivered as a report with week-over-week deltas and alerts.\n\n**Setup:**\n1. Create a Google Sheet with five tabs (see the Data Model note to the right) and fill in your prompts and brands\n2. Create an HTTP Header Auth credential for OpenRouter: header `Authorization`, value `Bearer sk-or-v1-...` (openrouter.ai/keys)\n3. Assign a Google Sheets (OAuth2) credential to all five Sheets nodes and replace PASTE_SHEET_ID with your spreadsheet ID\n4. Create an SMTP credential and set sender and recipient in the Send Email node\n5. Test run: activate one prompt, set N_RUNS=1, execute manually, then enable the schedule\n\n**Customization:** model list and N_RUNS in the Expand Runs node, alert thresholds in the Build Report node, sending cadence in the Schedule Trigger.",
        "height": 660,
        "width": 640
      },
      "id": "e1000000-0000-0000-0000-000000000001",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-60, -420]
    },
    {
      "parameters": {
        "content": "## Data Model (5 tabs in the Google Sheet)\n\n**prompts**: prompt_id, text, topic, market, language, active\n\n**brands**: brand_id, name, aliases_regex, domains (comma-separated), is_own (TRUE/FALSE)\n\n**runs**: run_id, timestamp, model, prompt_id, run_index, status, answer_text, citations_json\n\n**mentions**: run_id, model, prompt_id, brand_id, brand_name, is_own, mentioned, cited, first_position\n\n**weekly_agg**: week, model, brand_id, brand_name, is_own, n_runs, mention_rate, citation_rate, sov, avg_position\n\nCreate all tabs with exactly these headers before the first run. runs, mentions and weekly_agg stay empty otherwise, the workflow fills them.",
        "height": 660,
        "width": 460,
        "color": 7
      },
      "id": "e1000000-0000-0000-0000-000000000002",
      "name": "Data Model",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [620, -420]
    },
    {
      "parameters": {
        "content": "## 1. Data Collection via OpenRouter\n\nActive prompts are expanded into prompt x model x N_RUNS and queried one by one through OpenRouter (a single API key for all four models). perplexity/sonar searches natively; the other models use the web plugin with engine native so that each provider's own search engine is measured. Every answer is normalized into text plus source URLs and stored as a raw record in the runs tab. Failing API calls do not stop the run, they land in the sheet as status=error.",
        "height": 540,
        "width": 1580,
        "color": 4
      },
      "id": "e1000000-0000-0000-0000-000000000003",
      "name": "Phase 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-60, 320]
    },
    {
      "parameters": {
        "content": "## 2. Mention Analysis\n\nEvery answer from today's run is checked deterministically via regex against all brands: mentioned yes/no, domain cited as a source yes/no, relative position of the first mention. No LLM judge, every number stays reproducible. Result: one row per brand and run in the mentions tab.",
        "height": 540,
        "width": 1080,
        "color": 5
      },
      "id": "e1000000-0000-0000-0000-000000000004",
      "name": "Phase 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [1560, 320]
    },
    {
      "parameters": {
        "content": "## 3. Weekly Aggregation\n\nPer model x brand (plus a combined 'all' value) the workflow calculates Mention Rate, Citation Rate, Share of Voice and average position, and writes them to the weekly_agg tab. This tab is the data source for dashboards, e.g. Looker Studio.",
        "height": 540,
        "width": 700,
        "color": 6
      },
      "id": "e1000000-0000-0000-0000-000000000005",
      "name": "Phase 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [2680, 320]
    },
    {
      "parameters": {
        "content": "## 4. Email Report\n\nCompares the current week with the previous week and builds an HTML report: own-brand table per model, SoV ranking of all brands, alerts on a SoV drop of more than 5 percentage points or a Citation Rate of 0. Delivered via SMTP to any number of recipients.",
        "height": 540,
        "width": 520,
        "color": 3
      },
      "id": "e1000000-0000-0000-0000-000000000006",
      "name": "Phase 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [3420, 320]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [1],
              "triggerAtHour": 6
            }
          ]
        }
      },
      "id": "e1000000-0000-0000-0000-000000000007",
      "name": "Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [20, 520]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "prompts",
          "mode": "name"
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000008",
      "name": "Read Prompts",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [240, 520]
    },
    {
      "parameters": {
        "jsCode": "// Expansion: every active prompt x every model x N_RUNS repetitions.\n// One output item = one upcoming API call.\n\nconst MODELS = [\n  { model: 'perplexity/sonar', plugins: null },\n  { model: 'openai/gpt-4o-mini', plugins: [{ id: 'web', engine: 'native' }] },\n  { model: 'google/gemini-2.5-flash', plugins: [{ id: 'web', engine: 'native' }] },\n  { model: 'anthropic/claude-sonnet-4.5', plugins: [{ id: 'web', engine: 'native' }] }\n];\nconst N_RUNS = 3;\n\nconst today = new Date().toISOString().slice(0, 10);\nconst out = [];\n\nfor (const item of $input.all()) {\n  const p = item.json;\n  if (String(p.active).toUpperCase() !== 'TRUE') continue;\n\n  for (const m of MODELS) {\n    for (let i = 1; i <= N_RUNS; i++) {\n      const body = {\n        model: m.model,\n        messages: [{ role: 'user', content: p.text }],\n        max_tokens: 1024\n      };\n      if (m.plugins) body.plugins = m.plugins;\n\n      const modelShort = m.model.split('/')[1].replace(/[^a-z0-9.-]/gi, '');\n      out.push({ json: {\n        run_id: `${today}_${p.prompt_id}_${modelShort}_${i}`,\n        prompt_id: p.prompt_id,\n        prompt_text: p.text,\n        model: m.model,\n        run_index: i,\n        request_body: body\n      }});\n    }\n  }\n}\n\nreturn out;"
      },
      "id": "e1000000-0000-0000-0000-000000000009",
      "name": "Expand Runs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [460, 520]
    },
    {
      "parameters": {
        "batchSize": 1,
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000010",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [680, 520]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.request_body) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "e1000000-0000-0000-0000-000000000011",
      "name": "Call OpenRouter",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [740, 700],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Normalize the OpenRouter response into the unified schema for the runs tab.\n// Sources: annotations[].url_citation.url (OpenRouter standard for all models),\n// fallback to resp.citations (legacy Perplexity field).\n\nconst meta = $('Loop Over Items').item.json;\nconst resp = $input.item.json;\n\nlet answer = '';\nlet citations = [];\nlet status = 'ok';\n\nif (resp && resp.error) {\n  status = 'error: ' + String(resp.error.message || JSON.stringify(resp.error)).slice(0, 200);\n} else {\n  const msg = (resp && resp.choices && resp.choices[0] && resp.choices[0].message) || {};\n  answer = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || '');\n\n  if (Array.isArray(msg.annotations)) {\n    citations = msg.annotations\n      .filter(a => a.type === 'url_citation')\n      .map(a => (a.url_citation && a.url_citation.url) || a.url)\n      .filter(Boolean);\n  }\n  if (citations.length === 0 && Array.isArray(resp.citations)) {\n    citations = resp.citations;\n  }\n  if (!answer) status = 'empty';\n}\n\nreturn [{ json: {\n  run_id: meta.run_id,\n  timestamp: new Date().toISOString(),\n  model: meta.model,\n  prompt_id: meta.prompt_id,\n  run_index: meta.run_index,\n  status,\n  answer_text: answer.slice(0, 45000),\n  citations_json: JSON.stringify([...new Set(citations)])\n}}];"
      },
      "id": "e1000000-0000-0000-0000-000000000012",
      "name": "Normalize Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [940, 700]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "runs",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000013",
      "name": "Append to runs",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [1140, 700]
    },
    {
      "parameters": {
        "amount": 2,
        "unit": "seconds"
      },
      "id": "e1000000-0000-0000-0000-000000000014",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [1340, 700]
    },
    {
      "parameters": {},
      "id": "e1000000-0000-0000-0000-000000000015",
      "name": "Collection Complete",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [1620, 520]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "brands",
          "mode": "name"
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000016",
      "name": "Read Brands",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [1820, 520]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "runs",
          "mode": "name"
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000017",
      "name": "Read Runs",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [2020, 520]
    },
    {
      "parameters": {
        "jsCode": "// Mention and citation analysis: only today's runs with status=ok.\n// Fully deterministic, no LLM judge. One row = one brand in one run.\n\nconst brands = $('Read Brands').all().map(i => i.json);\nconst today = new Date().toISOString().slice(0, 10);\nconst out = [];\n\nfor (const item of $input.all()) {\n  const r = item.json;\n  if (!String(r.run_id || '').startsWith(today)) continue;\n  if (String(r.status || '') !== 'ok') continue;\n\n  const text = String(r.answer_text || '');\n  let cites = [];\n  try { cites = JSON.parse(r.citations_json || '[]'); } catch (e) { cites = []; }\n\n  for (const b of brands) {\n    let re;\n    try {\n      re = new RegExp(b.aliases_regex, 'i');\n    } catch (e) {\n      re = new RegExp(String(b.name || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'i');\n    }\n    const m = text.match(re);\n    const domains = String(b.domains || '').split(',').map(d => d.trim()).filter(Boolean);\n\n    out.push({ json: {\n      run_id: r.run_id,\n      model: r.model,\n      prompt_id: r.prompt_id,\n      brand_id: b.brand_id,\n      brand_name: b.name,\n      is_own: String(b.is_own).toUpperCase() === 'TRUE',\n      mentioned: !!m,\n      cited: cites.some(u => domains.some(d => String(u).includes(d))),\n      first_position: m ? +(m.index / Math.max(text.length, 1)).toFixed(3) : ''\n    }});\n  }\n}\n\nreturn out;"
      },
      "id": "e1000000-0000-0000-0000-000000000018",
      "name": "Detect Mentions",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2220, 520]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "mentions",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000019",
      "name": "Append to mentions",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [2420, 520]
    },
    {
      "parameters": {
        "jsCode": "// Weekly aggregation per model x brand plus a combined value across all models ('all').\n// mention_rate = runs with a mention / all runs\n// citation_rate = runs with a domain citation / all runs\n// sov = brand mentions / sum of mentions across all brands\n// avg_position = mean relative position of the first mention (0 = start of the answer)\n\nconst rows = $('Detect Mentions').all().map(i => i.json);\nif (rows.length === 0) return [];\n\nfunction isoWeek(d) {\n  const dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\n  const day = dt.getUTCDay() || 7;\n  dt.setUTCDate(dt.getUTCDate() + 4 - day);\n  const year = dt.getUTCFullYear();\n  const start = new Date(Date.UTC(year, 0, 1));\n  const week = Math.ceil((((dt - start) / 86400000) + 1) / 7);\n  return year + '-W' + String(week).padStart(2, '0');\n}\nconst week = isoWeek(new Date());\n\nconst isTrue = v => v === true || String(v).toUpperCase() === 'TRUE';\nconst models = [...new Set(rows.map(r => r.model))];\nmodels.push('all');\n\nconst out = [];\nfor (const model of models) {\n  const subset = model === 'all' ? rows : rows.filter(r => r.model === model);\n  const nRuns = new Set(subset.map(r => r.run_id)).size;\n  const brandIds = [...new Set(subset.map(r => r.brand_id))];\n\n  const stats = brandIds.map(bid => {\n    const bRows = subset.filter(r => r.brand_id === bid);\n    const mentions = bRows.filter(r => isTrue(r.mentioned));\n    const cited = bRows.filter(r => isTrue(r.cited));\n    const positions = mentions.map(r => parseFloat(r.first_position)).filter(v => !isNaN(v));\n    return {\n      bid,\n      name: bRows[0].brand_name,\n      is_own: isTrue(bRows[0].is_own),\n      mentions: mentions.length,\n      cited: cited.length,\n      positions\n    };\n  });\n  const totalMentions = stats.reduce((s, x) => s + x.mentions, 0);\n\n  for (const st of stats) {\n    out.push({ json: {\n      week,\n      model,\n      brand_id: st.bid,\n      brand_name: st.name,\n      is_own: st.is_own,\n      n_runs: nRuns,\n      mention_rate: nRuns ? +(st.mentions / nRuns).toFixed(3) : 0,\n      citation_rate: nRuns ? +(st.cited / nRuns).toFixed(3) : 0,\n      sov: totalMentions ? +(st.mentions / totalMentions).toFixed(3) : 0,\n      avg_position: st.positions.length ? +(st.positions.reduce((a, b) => a + b, 0) / st.positions.length).toFixed(3) : ''\n    }});\n  }\n}\n\nreturn out;"
      },
      "id": "e1000000-0000-0000-0000-000000000020",
      "name": "Calculate Weekly Metrics",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2740, 520]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "weekly_agg",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000021",
      "name": "Append to weekly_agg",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [2940, 520]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "PASTE_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "weekly_agg",
          "mode": "name"
        },
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000022",
      "name": "Read weekly_agg",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [3140, 520]
    },
    {
      "parameters": {
        "jsCode": "// HTML visibility report: own-brand overview per model, SoV ranking of all brands,\n// alerts on a SoV drop of more than 5 percentage points or a Citation Rate of 0.\n// Output: subject and html for the email delivery.\n\nconst agg = $input.all().map(i => i.json);\nconst current = $('Calculate Weekly Metrics').all().map(i => i.json);\n\nconst num = v => { const n = parseFloat(v); return isNaN(n) ? 0 : n; };\nconst pct = v => Math.round(num(v) * 100) + '%';\nconst isTrue = v => v === true || String(v).toUpperCase() === 'TRUE';\n\nif (current.length === 0) {\n  return [{ json: {\n    subject: 'GEO SoV Monitor: no data in this run',\n    html: '<p>No usable data in this run. Please check the execution log in n8n.</p>'\n  }}];\n}\n\nconst week = current[0].week;\nconst prevWeeks = [...new Set(agg.map(r => String(r.week)).filter(w => w && w < week))].sort();\nconst prevWeek = prevWeeks[prevWeeks.length - 1] || null;\nconst prev = prevWeek ? agg.filter(r => String(r.week) === prevWeek) : [];\n\nconst findPrev = (bid, model) => prev.find(r => String(r.brand_id) === String(bid) && String(r.model) === String(model));\nconst deltaPp = (cur, field) => {\n  const p = findPrev(cur.brand_id, cur.model);\n  if (!p) return 'new';\n  const d = Math.round((num(cur[field]) - num(p[field])) * 100);\n  if (d > 0) return '+' + d + ' pp';\n  if (d < 0) return d + ' pp';\n  return '0 pp';\n};\n\nconst ownRows = current.filter(r => isTrue(r.is_own));\nconst ownAll = ownRows.find(r => r.model === 'all');\nconst ownName = ownRows[0] ? ownRows[0].brand_name : 'Own brand';\n\nconst alerts = [];\nif (prevWeek) {\n  for (const cur of ownRows) {\n    const p = findPrev(cur.brand_id, cur.model);\n    if (!p) continue;\n    if (num(cur.sov) - num(p.sov) <= -0.05) {\n      alerts.push('SoV drop on ' + cur.model + ': from ' + pct(p.sov) + ' to ' + pct(cur.sov));\n    }\n    if (num(cur.citation_rate) === 0 && num(p.citation_rate) > 0) {\n      alerts.push('Citation Rate on ' + cur.model + ' dropped to 0. Check crawler access to the domain (robots.txt, WAF, status codes).');\n    }\n  }\n}\n\nconst td = `style='padding:6px 10px;border:1px solid #ddd;font-size:14px'`;\nconst th = `style='padding:6px 10px;border:1px solid #ddd;font-size:13px;background:#f2f2f2;text-align:left'`;\n\nconst modelOrder = ['all', ...[...new Set(current.map(r => r.model))].filter(m => m !== 'all').sort()];\nlet ownTable = '';\nfor (const m of modelOrder) {\n  const r = ownRows.find(x => x.model === m);\n  if (!r) continue;\n  const label = m === 'all' ? '<strong>All models</strong>' : m;\n  const posVal = r.avg_position !== '' && r.avg_position !== undefined ? num(r.avg_position).toFixed(2) : '-';\n  ownTable += `<tr><td ${td}>${label}</td><td ${td}>${pct(r.sov)} (${deltaPp(r, 'sov')})</td><td ${td}>${pct(r.mention_rate)} (${deltaPp(r, 'mention_rate')})</td><td ${td}>${pct(r.citation_rate)} (${deltaPp(r, 'citation_rate')})</td><td ${td}>${posVal}</td></tr>`;\n}\n\nconst ranking = current.filter(r => r.model === 'all').sort((a, b) => num(b.sov) - num(a.sov));\nlet rankTable = '';\nranking.forEach((r, i) => {\n  const weight = isTrue(r.is_own) ? 'font-weight:bold;background:#f5faff' : '';\n  rankTable += `<tr style='${weight}'><td ${td}>${i + 1}</td><td ${td}>${r.brand_name}</td><td ${td}>${pct(r.sov)} (${deltaPp(r, 'sov')})</td><td ${td}>${pct(r.mention_rate)}</td><td ${td}>${pct(r.citation_rate)}</td></tr>`;\n});\n\nconst alertsHtml = alerts.length\n  ? `<div style='background:#fdecea;border:1px solid #e57373;border-radius:6px;padding:12px 16px;margin:20px 0'><p style='margin:0;font-weight:bold'>Alerts</p><ul style='margin:8px 0 0;padding-left:20px'>` + alerts.map(a => `<li style='margin-bottom:4px'>${a}</li>`).join('') + `</ul></div>`\n  : '';\n\nconst firstNote = prevWeek\n  ? ''\n  : `<p style='color:#666'>First measurement. Week-over-week values in parentheses will appear from next week on.</p>`;\n\nconst compareNote = prevWeek ? `<p style='color:#666;font-size:13px'>Values in parentheses: change vs. week ${prevWeek} in percentage points (pp).</p>` : '';\n\nconst html = `\n<div style='font-family:Arial,Helvetica,sans-serif;color:#222;max-width:720px'>\n  <h2 style='margin-bottom:4px'>AI Visibility Report, week ${week}</h2>\n  <p style='margin-top:0;color:#666'>Brand: ${ownName}. Data basis: ${ownAll ? ownAll.n_runs : '?'} runs across ${modelOrder.length - 1} AI models.</p>\n  ${firstNote}\n  ${alertsHtml}\n  <h3 style='margin-bottom:8px'>${ownName} by model</h3>\n  <table style='border-collapse:collapse;width:100%'>\n    <tr><th ${th}>Model</th><th ${th}>Share of Voice</th><th ${th}>Mention Rate</th><th ${th}>Citation Rate</th><th ${th}>Avg. Position</th></tr>\n    ${ownTable}\n  </table>\n  <h3 style='margin-bottom:8px;margin-top:24px'>SoV ranking of all brands (all models)</h3>\n  <table style='border-collapse:collapse;width:100%'>\n    <tr><th ${th}>#</th><th ${th}>Brand</th><th ${th}>Share of Voice</th><th ${th}>Mention Rate</th><th ${th}>Citation Rate</th></tr>\n    ${rankTable}\n  </table>\n  ${compareNote}\n  <p style='color:#999;font-size:12px;margin-top:24px'>Automatically generated by the GEO SoV Monitor (n8n). Raw data and weekly metrics live in the connected Google Sheet.</p>\n</div>`;\n\nconst subject = 'AI Visibility Report ' + week + ': ' + ownName + ' SoV ' + (ownAll ? pct(ownAll.sov) : 'n/a') + (alerts.length ? ' (' + alerts.length + ' alert' + (alerts.length > 1 ? 's' : '') + ')' : '');\n\nreturn [{ json: { subject, html } }];"
      },
      "id": "e1000000-0000-0000-0000-000000000023",
      "name": "Build Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [3480, 520]
    },
    {
      "parameters": {
        "fromEmail": "PASTE_SENDER@domain.tld",
        "toEmail": "PASTE_RECIPIENT@domain.tld",
        "subject": "={{ $json.subject }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {}
      },
      "id": "e1000000-0000-0000-0000-000000000024",
      "name": "Send Email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [3700, 520]
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Read Prompts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Prompts": {
      "main": [
        [
          {
            "node": "Expand Runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand Runs": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [
          {
            "node": "Collection Complete",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Call OpenRouter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call OpenRouter": {
      "main": [
        [
          {
            "node": "Normalize Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Response": {
      "main": [
        [
          {
            "node": "Append to runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to runs": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collection Complete": {
      "main": [
        [
          {
            "node": "Read Brands",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Brands": {
      "main": [
        [
          {
            "node": "Read Runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Runs": {
      "main": [
        [
          {
            "node": "Detect Mentions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Mentions": {
      "main": [
        [
          {
            "node": "Append to mentions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to mentions": {
      "main": [
        [
          {
            "node": "Calculate Weekly Metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Weekly Metrics": {
      "main": [
        [
          {
            "node": "Append to weekly_agg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append to weekly_agg": {
      "main": [
        [
          {
            "node": "Read weekly_agg",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read weekly_agg": {
      "main": [
        [
          {
            "node": "Build Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Report": {
      "main": [
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
