{
  "name": "Daily SEO and AI Search Watch (Zabio)",
  "connections": {
    "Every Morning": {
      "main": [
        [
          {
            "node": "SEO Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SEO Settings": {
      "main": [
        [
          {
            "node": "Load Sites",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Sites": {
      "main": [
        [
          {
            "node": "Build Google Requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Google Requests": {
      "main": [
        [
          {
            "node": "Call Google APIs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Call Google APIs": {
      "main": [
        [
          {
            "node": "Collect Google Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Google Data": {
      "main": [
        [
          {
            "node": "Build Page Checks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Page Checks": {
      "main": [
        [
          {
            "node": "Fetch Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Pages": {
      "main": [
        [
          {
            "node": "Read Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Pages": {
      "main": [
        [
          {
            "node": "Load Page States",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Page States": {
      "main": [
        [
          {
            "node": "Load Open Issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Open Issues": {
      "main": [
        [
          {
            "node": "Pick Pages To Inspect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Pages To Inspect": {
      "main": [
        [
          {
            "node": "Anything To Inspect?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything To Inspect?": {
      "main": [
        [
          {
            "node": "Split Inspections",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Find Issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Inspections": {
      "main": [
        [
          {
            "node": "Inspect URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inspect URLs": {
      "main": [
        [
          {
            "node": "Find Issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Issues": {
      "main": [
        [
          {
            "node": "Page State Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Issue Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Site Results",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Urgent Alerts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Page State Rows": {
      "main": [
        [
          {
            "node": "Save Page States",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Issue Rows": {
      "main": [
        [
          {
            "node": "Save Issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Site Results": {
      "main": [
        [
          {
            "node": "Build Brief Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Brief Request": {
      "main": [
        [
          {
            "node": "Write Brief With Zabio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Brief With Zabio": {
      "main": [
        [
          {
            "node": "Render Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Report": {
      "main": [
        [
          {
            "node": "Email Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Urgent Alerts": {
      "main": [
        [
          {
            "node": "Alert Channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert Channel": {
      "main": [
        [
          {
            "node": "WhatsApp Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Discord Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Telegram Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "nodes": [
    {
      "id": "f241d04f-7561-4458-ab93-1d4c2cf4d07d",
      "name": "Every Morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        288
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 7,
              "triggerAtMinute": 0
            }
          ]
        }
      }
    },
    {
      "id": "a23b250d-3a27-4aa5-9fb8-e994411c4056",
      "name": "SEO Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        224,
        288
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "ss-from",
              "name": "email_from",
              "value": "SEO Watch <seo@example.com>",
              "type": "string"
            },
            {
              "id": "ss-model",
              "name": "zabio_model",
              "value": "auto",
              "type": "string"
            },
            {
              "id": "ss-top",
              "name": "top_pages",
              "value": 50,
              "type": "number"
            },
            {
              "id": "ss-insp",
              "name": "max_inspections",
              "value": 10,
              "type": "number"
            },
            {
              "id": "ss-drop",
              "name": "drop_pct",
              "value": 30,
              "type": "number"
            },
            {
              "id": "ss-minc",
              "name": "min_weekly_clicks",
              "value": 20,
              "type": "number"
            },
            {
              "id": "ss-pdrop",
              "name": "page_drop_pct",
              "value": 40,
              "type": "number"
            },
            {
              "id": "ss-pminc",
              "name": "page_min_weekly_clicks",
              "value": 10,
              "type": "number"
            },
            {
              "id": "ss-conv",
              "name": "conversion_drop_pct",
              "value": 30,
              "type": "number"
            },
            {
              "id": "ss-money",
              "name": "money_position_drop",
              "value": 3,
              "type": "number"
            },
            {
              "id": "ss-ai",
              "name": "ai_sources",
              "value": "chatgpt|openai|perplexity|gemini|bard|copilot|claude|anthropic|deepseek|meta.ai|you.com|phind|grok|mistral",
              "type": "string"
            },
            {
              "id": "ss-ua",
              "name": "user_agent",
              "value": "Mozilla/5.0 (compatible; SEOWatch/1.0; n8n template)",
              "type": "string"
            },
            {
              "id": "ss-wat",
              "name": "wa_template",
              "value": "",
              "type": "string"
            },
            {
              "id": "ss-wap",
              "name": "wa_phone_number_id",
              "value": "",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "305f5bc0-30a7-496d-9200-284b8c7cfe4f",
      "name": "Load Sites",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        448,
        288
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_sites"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "active",
              "condition": "isTrue"
            }
          ]
        },
        "returnAll": true
      }
    },
    {
      "id": "87eecb0f-c9bf-431d-adcd-41b8eaa867e3",
      "name": "Build Google Requests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        672,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One request per data set and site. Search Console data is final about 3 days later,\n// so the last 7 days end 3 days ago and are compared with the 28 days before.\nconst s = $('SEO Settings').first().json;\nconst sites = $input.all().map(i => i.json).filter(x => x.site_id && x.domain);\nconst d = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);\nconst out = [];\nfor (const site of sites) {\n  if (site.gsc_property) {\n    const url = 'https://searchconsole.googleapis.com/webmasters/v3/sites/' + encodeURIComponent(site.gsc_property) + '/searchAnalytics/query';\n    const add = (kind, body) => out.push({ json: { site_id: site.site_id, kind: kind, url: url, body: body } });\n    add('gsc_totals', { startDate: d(37), endDate: d(3), dimensions: ['date'], rowLimit: 100 });\n    add('gsc_pages_recent', { startDate: d(9), endDate: d(3), dimensions: ['page'], rowLimit: 1000 });\n    add('gsc_pages_prev', { startDate: d(37), endDate: d(10), dimensions: ['page'], rowLimit: 1000 });\n    add('gsc_queries_recent', { startDate: d(9), endDate: d(3), dimensions: ['query'], rowLimit: 1000 });\n    add('gsc_queries_prev', { startDate: d(37), endDate: d(10), dimensions: ['query'], rowLimit: 1000 });\n  }\n  if (site.ga4_property) {\n    const prop = String(site.ga4_property).replace(/[^0-9]/g, '');\n    const ranges = [{ startDate: '7daysAgo', endDate: 'yesterday', name: 'recent' }, { startDate: '35daysAgo', endDate: '8daysAgo', name: 'prev' }];\n    const metrics = [{ name: 'sessions' }, { name: 'keyEvents' }];\n    out.push({ json: { site_id: site.site_id, kind: 'ga4', url: 'https://analyticsdata.googleapis.com/v1beta/properties/' + prop + ':batchRunReports', body: { requests: [\n      { dateRanges: [{ startDate: '35daysAgo', endDate: 'yesterday' }], dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }], metrics: metrics, limit: 5000 },\n      { dateRanges: ranges, dimensions: [{ name: 'landingPage' }], metrics: metrics, orderBys: [{ metric: { metricName: 'sessions' }, desc: true }], limit: 1000 },\n      { dateRanges: ranges, dimensions: [{ name: 'sessionSource' }], metrics: metrics, dimensionFilter: { filter: { fieldName: 'sessionSource', stringFilter: { matchType: 'PARTIAL_REGEXP', value: s.ai_sources, caseSensitive: false } } }, limit: 200 }\n    ] } } });\n  }\n}\nreturn out;"
      }
    },
    {
      "id": "af2c3952-a088-461d-9a15-4fbb501bb3a9",
      "name": "Call Google APIs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        896,
        288
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.url }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body) }}",
        "options": {
          "timeout": 60000,
          "batching": {
            "batch": {
              "batchSize": 5,
              "batchInterval": 200
            }
          }
        }
      },
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 2
    },
    {
      "id": "01abf536-4c5d-470f-9b22-60aeb8212f63",
      "name": "Collect Google Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Groups every API answer by site. Errors are kept so the report can say what could not be read.\nconst data = {};\n$input.all().forEach((item, i) => {\n  const r = $('Build Google Requests').itemMatching(i).json;\n  const d = data[r.site_id] || (data[r.site_id] = { site_id: r.site_id, errors: [] });\n  const j = item.json || {};\n  if (j.error) {\n    const e = j.error;\n    d.errors.push({ kind: r.kind, message: String((e && (e.message || e.description)) || JSON.stringify(e)).slice(0, 300) });\n    return;\n  }\n  if (r.kind === 'ga4') { d.ga4 = j.reports || []; return; }\n  d[r.kind] = (j.rows || []).map(x => ({ key: x.keys[0], clicks: Number(x.clicks) || 0, impressions: Number(x.impressions) || 0, ctr: Number(x.ctr) || 0, position: Number(x.position) || 0 }));\n});\nreturn Object.values(data).map(d => ({ json: d }));"
      }
    },
    {
      "id": "8e8d7c69-a2eb-4837-9efc-ddf6d7ed37e8",
      "name": "Build Page Checks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1344,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The home page, robots.txt and the pages that get the most search clicks.\nconst s = $('SEO Settings').first().json;\nconst sites = $('Load Sites').all().map(i => i.json).filter(x => x.site_id && x.domain);\nconst data = {};\nfor (const i of $input.all()) data[i.json.site_id] = i.json;\nconst out = [];\nfor (const site of sites) {\n  const om = String(site.domain).trim().match(/^https?:[/][/][^/?#]+/i);\n  if (!om) continue;\n  const origin = om[0].toLowerCase();\n  const urls = new Set([origin + '/']);\n  const pages = ((data[site.site_id] || {}).gsc_pages_recent || []).slice().sort((a, b) => b.clicks - a.clicks);\n  for (const p of pages.slice(0, Number(s.top_pages) || 50)) urls.add(p.key);\n  for (const u of urls) out.push({ json: { site_id: site.site_id, kind: 'page', url: u } });\n  out.push({ json: { site_id: site.site_id, kind: 'robots', url: origin + '/robots.txt' } });\n}\nreturn out;"
      }
    },
    {
      "id": "a31fcbd0-39f1-4955-9914-4cff6e369df5",
      "name": "Fetch Pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1568,
        288
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "={{ $(\"SEO Settings\").first().json.user_agent }}"
            }
          ]
        },
        "options": {
          "timeout": 20000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          },
          "batching": {
            "batch": {
              "batchSize": 5,
              "batchInterval": 300
            }
          }
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "a6cb78a2-a142-466d-92a0-2977b1d6ec70",
      "name": "Read Pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1792,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pulls the SEO signals out of each page: status, title, description, robots and canonical.\nconst WS = new RegExp('[' + String.fromCharCode(9, 10, 13, 32, 160) + ']+', 'g');\nconst decode = t => String(t || '').replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&#0?39;/g, \"'\").replace(/&lt;/g, '<').replace(/&gt;/g, '>');\nconst clean = t => decode(t).replace(WS, ' ').trim();\nconst attr = (tag, name) => { const m = tag.match(new RegExp(name + '=[\"]([^\"]*)[\"]', 'i')) || tag.match(new RegExp(name + \"=[']([^']*)[']\", 'i')); return m ? m[1] : ''; };\nfunction meta(html, name) {\n  for (const tag of html.match(/<meta[^>]+>/gi) || []) {\n    if (attr(tag, 'name').toLowerCase() === name) return attr(tag, 'content');\n  }\n  return '';\n}\nfunction canonical(html) {\n  for (const tag of html.match(/<link[^>]+>/gi) || []) {\n    if (attr(tag, 'rel').toLowerCase().split(' ').includes('canonical')) return attr(tag, 'href');\n  }\n  return '';\n}\nfunction hash(t) { let h = 5381; for (let k = 0; k < t.length; k++) h = ((h << 5) + h + t.charCodeAt(k)) | 0; return (h >>> 0).toString(16); }\n\nconst out = [];\n$input.all().forEach((item, i) => {\n  const req = $('Build Page Checks').itemMatching(i).json;\n  const j = item.json || {};\n  const headers = j.headers || {};\n  let body = j.body !== undefined ? j.body : (j.data !== undefined ? j.data : '');\n  if (typeof body !== 'string') body = JSON.stringify(body || '');\n  const rec = {\n    site_id: req.site_id, kind: req.kind, url: req.url,\n    status: Number(j.statusCode || 0),\n    error: j.error ? String(j.error.message || 'The page could not be fetched').slice(0, 200) : '',\n    location: String(headers.location || ''),\n    title: '', description: '', robots: '', canonical: '', body_hash: '', robots_txt: ''\n  };\n  if (req.kind === 'robots') {\n    rec.robots_txt = body.slice(0, 50000);\n    rec.body_hash = rec.status === 200 ? hash(body) : '';\n  } else {\n    const html = body.slice(0, 300000);\n    const t = html.match(/<title[^>]*>([^<]*)<[/]title>/i);\n    rec.title = clean(t ? t[1] : '');\n    rec.description = clean(meta(html, 'description'));\n    rec.robots = (clean(meta(html, 'robots')) + ' ' + String(headers['x-robots-tag'] || '')).trim().toLowerCase();\n    rec.canonical = clean(canonical(html));\n  }\n  out.push({ json: rec });\n});\nreturn out;"
      }
    },
    {
      "id": "4cbf9230-1ed5-4749-8e62-84552a895936",
      "name": "Load Page States",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2016,
        288
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_pages"
        },
        "returnAll": true
      },
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "id": "be1c9ba0-9f34-4321-b92f-5eb77d133c85",
      "name": "Load Open Issues",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2240,
        288
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_issues"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "status",
              "condition": "eq",
              "keyValue": "open"
            }
          ]
        },
        "returnAll": true
      },
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "id": "722f369d-9dd9-4342-8063-89ca3f65d873",
      "name": "Pick Pages To Inspect",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2464,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Google's URL Inspection is limited per day, so only pages worth a look are checked:\n// pages that lost clicks, pages with noindex or a canonical elsewhere, and earlier indexing issues.\nconst s = $('SEO Settings').first().json;\nconst sites = $('Load Sites').all().map(i => i.json).filter(x => x.site_id && x.gsc_property);\nconst data = {};\nfor (const i of $('Collect Google Data').all()) data[i.json.site_id] = i.json;\nconst pages = $('Read Pages').all().map(i => i.json);\nconst open = $('Load Open Issues').all().map(i => i.json).filter(x => x.site_id);\nconst norm = u => String(u || '').trim().toLowerCase().replace(/#.*$/, '').replace(/[/]+$/, '');\nconst out = [];\nfor (const site of sites) {\n  const d = data[site.site_id] || {};\n  const urls = [];\n  const add = u => { if (u && !urls.includes(u)) urls.push(u); };\n  const prev = {};\n  for (const p of d.gsc_pages_prev || []) prev[p.key] = p.clicks / 4;\n  const recent = {};\n  for (const p of d.gsc_pages_recent || []) recent[p.key] = p.clicks;\n  Object.keys(prev)\n    .filter(k => prev[k] >= Number(s.page_min_weekly_clicks) && ((recent[k] || 0) - prev[k]) / prev[k] * 100 <= -Number(s.page_drop_pct))\n    .sort((a, b) => (prev[b] - (recent[b] || 0)) - (prev[a] - (recent[a] || 0)))\n    .forEach(add);\n  pages.filter(p => p.site_id === site.site_id && p.kind === 'page' && p.status === 200 && (p.robots.includes('noindex') || (p.canonical && norm(p.canonical) !== norm(p.url)))).forEach(p => add(p.url));\n  open.filter(o => o.site_id === site.site_id && (o.type === 'not_indexed' || o.type === 'google_canonical')).forEach(o => add(o.page));\n  for (const u of urls.slice(0, Number(s.max_inspections) || 10)) out.push({ site_id: site.site_id, site_url: site.gsc_property, url: u });\n}\nreturn [{ json: { inspect: out } }];"
      }
    },
    {
      "id": "f7d92003-2fe4-42d3-98e1-7ac9acb45f70",
      "name": "Anything To Inspect?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2688,
        288
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "leftValue": "={{ $json.inspect.length > 0 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "9bb5ef02-2012-46cf-a47e-1770e57407a6",
      "name": "Split Inspections",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2912,
        224
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $input.first().json.inspect.map(x => ({ json: x }));"
      }
    },
    {
      "id": "38e59d41-1677-4c52-b291-9aa9e297b1b6",
      "name": "Inspect URLs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3136,
        224
      ],
      "parameters": {
        "method": "POST",
        "url": "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ inspectionUrl: $json.url, siteUrl: $json.site_url, languageCode: \"en-US\" }) }}",
        "options": {
          "timeout": 60000,
          "batching": {
            "batch": {
              "batchSize": 3,
              "batchInterval": 1000
            }
          }
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "d5dcdc82-9815-4aaf-8d5b-ee983a06d6e7",
      "name": "Find Issues",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3360,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Every check is plain arithmetic on the data: the AI only explains the results later.\nconst s = $('SEO Settings').first().json;\nconst sites = $('Load Sites').all().map(i => i.json).filter(x => x.site_id && x.domain);\nconst data = {};\nfor (const i of $('Collect Google Data').all()) data[i.json.site_id] = i.json;\nconst pages = $('Read Pages').all().map(i => i.json);\nconst states = $('Load Page States').all().map(i => i.json).filter(x => x.site_id);\nconst open = $('Load Open Issues').all().map(i => i.json).filter(x => x.site_id);\nlet inspections = [];\ntry {\n  const reqs = $('Split Inspections').all().map(i => i.json);\n  const res = $('Inspect URLs').all().map(i => i.json);\n  inspections = reqs.map((r, k) => Object.assign({}, r, { result: res[k] || {} }));\n} catch (e) { inspections = []; }\n\nconst NL = String.fromCharCode(10);\nconst d = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);\nconst today = d(0);\nconst norm = u => String(u || '').trim().toLowerCase().replace(/#.*$/, '').replace(/[/]+$/, '');\nconst pct = (a, b) => (b ? Math.round((a - b) / b * 1000) / 10 : null);\nconst round = (v, n) => (v === null || v === undefined ? null : Math.round(v * Math.pow(10, n)) / Math.pow(10, n));\nconst parse = (v, def) => { try { const x = JSON.parse(v); return x == null ? def : x; } catch (e) { return def; } };\nconst RANK = { critical: 3, high: 2, medium: 1 };\nconst AI_BOTS = { gptbot: 'GPTBot', 'oai-searchbot': 'OAI-SearchBot', 'chatgpt-user': 'ChatGPT-User', claudebot: 'ClaudeBot', perplexitybot: 'PerplexityBot', 'google-extended': 'Google-Extended', ccbot: 'CCBot' };\n\nfunction totals(rows) {\n  const c = rows.reduce((a, x) => a + x.clicks, 0);\n  const im = rows.reduce((a, x) => a + x.impressions, 0);\n  const pos = im ? rows.reduce((a, x) => a + x.position * x.impressions, 0) / im : null;\n  return { clicks: c, impressions: im, ctr: im ? c / im : 0, position: pos };\n}\nfunction robotsGroups(txt) {\n  const groups = [];\n  let cur = null;\n  let lastAgent = false;\n  for (const raw of String(txt || '').split(NL)) {\n    const line = raw.split('#')[0].trim();\n    if (!line) continue;\n    const at = line.indexOf(':');\n    if (at < 0) continue;\n    const k = line.slice(0, at).trim().toLowerCase();\n    const v = line.slice(at + 1).trim();\n    if (k === 'user-agent') {\n      if (!cur || !lastAgent) { cur = { agents: [], disallow: [], allow: [] }; groups.push(cur); }\n      cur.agents.push(v.toLowerCase());\n      lastAgent = true;\n    } else {\n      lastAgent = false;\n      if (!cur) continue;\n      if (k === 'disallow') cur.disallow.push(v);\n      if (k === 'allow') cur.allow.push(v);\n    }\n  }\n  return groups;\n}\nfunction blocksAll(groups, agent) {\n  let g = groups.filter(x => x.agents.includes(agent));\n  if (!g.length) g = groups.filter(x => x.agents.includes('*'));\n  return g.some(x => x.disallow.includes('/') && !x.allow.includes('/'));\n}\nfunction ga4Rows(report) {\n  const dims = (report.dimensionHeaders || []).map(h => h.name);\n  const mets = (report.metricHeaders || []).map(h => h.name);\n  return (report.rows || []).map(r => {\n    const o = {};\n    (r.dimensionValues || []).forEach((v, k) => { o[dims[k]] = v.value; });\n    (r.metricValues || []).forEach((v, k) => { o[mets[k]] = Number(v.value) || 0; });\n    return o;\n  });\n}\n\nconst out = [];\nfor (const site of sites) {\n  const ES = site.language === 'es';\n  const L = (en, es) => (ES ? es : en);\n  const dd = data[site.site_id] || { errors: [] };\n  const issues = [];\n  const changes = [];\n  const ran = { gsc: false, ga4: false, robots: false, pages: new Set(), inspected: new Set() };\n  const add = (type, severity, page, label, detail) => issues.push({ issue_key: type + (page ? '|' + page : ''), type: type, severity: severity, page: page || '', label: label, detail: detail || {} });\n  const has = (type, page) => issues.some(i => i.type === type && i.page === page);\n  const kpi = {};\n  const om = String(site.domain).trim().match(/^https?:[/][/][^/?#]+/i);\n  const origin = om ? om[0].toLowerCase() : '';\n\n  // Search Console\n  for (const e of dd.errors || []) {\n    if (e.kind.startsWith('gsc') && !has('gsc_error', '')) add('gsc_error', 'medium', '', L('Search Console data could not be read', 'No se pudieron leer los datos de Search Console'), { message: e.message });\n    if (e.kind === 'ga4') add('ga4_error', 'medium', '', L('Google Analytics data could not be read', 'No se pudieron leer los datos de Google Analytics'), { message: e.message });\n  }\n  if (dd.gsc_totals && dd.gsc_pages_recent && dd.gsc_pages_prev) {\n    ran.gsc = true;\n    const cut = d(9);\n    const rows = dd.gsc_totals;\n    const rec = totals(rows.filter(r => r.key >= cut));\n    const prv = totals(rows.filter(r => r.key < cut));\n    const base = { clicks: prv.clicks / 4, impressions: prv.impressions / 4, ctr: prv.ctr, position: prv.position };\n    kpi.gsc = { from: cut, to: d(3), recent: rec, base: base };\n    const ch = pct(rec.clicks, base.clicks);\n    if (base.clicks >= Number(s.min_weekly_clicks) && ch !== null && ch <= -Number(s.drop_pct)) {\n      add('traffic_drop', 'high', '', L('Search clicks down ', 'Clics en Google bajaron un ') + Math.abs(ch) + '%', { last7: rec.clicks, weekly_avg: Math.round(base.clicks), change_pct: ch });\n    }\n    const chi = pct(rec.impressions, base.impressions);\n    if (base.impressions >= 200 && chi !== null && chi <= -Number(s.drop_pct)) {\n      add('impressions_drop', 'medium', '', L('Search impressions down ', 'Impresiones en Google bajaron un ') + Math.abs(chi) + '%', { last7: rec.impressions, weekly_avg: Math.round(base.impressions), change_pct: chi });\n    }\n    const prev = {};\n    for (const p of dd.gsc_pages_prev) prev[p.key] = p;\n    const recent = {};\n    for (const p of dd.gsc_pages_recent) recent[p.key] = p;\n    const drops = [];\n    for (const k of Object.keys(prev)) {\n      const b = prev[k].clicks / 4;\n      const r = recent[k] ? recent[k].clicks : 0;\n      const c = pct(r, b);\n      if (b >= Number(s.page_min_weekly_clicks) && c !== null && c <= -Number(s.page_drop_pct)) {\n        drops.push({ url: k, last7: r, weekly_avg: Math.round(b * 10) / 10, lost: Math.round(b - r), change_pct: c, position_now: recent[k] ? round(recent[k].position, 1) : null, position_before: round(prev[k].position, 1) });\n      }\n    }\n    drops.sort((a, b) => b.lost - a.lost).slice(0, 15).forEach(x => add('page_drop', 'high', x.url, L('Clicks down ', 'Clics bajaron un ') + Math.abs(x.change_pct) + L('% on this page', '% en esta página'), x));\n    if (dd.gsc_queries_recent && dd.gsc_queries_prev) {\n      const qr = {};\n      for (const q of dd.gsc_queries_recent) qr[q.key.toLowerCase()] = q;\n      const qp = {};\n      for (const q of dd.gsc_queries_prev) qp[q.key.toLowerCase()] = q;\n      for (const q of parse(site.money_queries, [])) {\n        const k = String(q).toLowerCase();\n        const b = qp[k];\n        const r = qr[k];\n        if (b && b.position <= 10 && (!r || r.position - b.position >= Number(s.money_position_drop))) {\n          add('money_query_drop', 'high', k, L('Ranking fell for \"', 'Bajó la posición para \"') + k + '\"', { position_before: round(b.position, 1), position_now: r ? round(r.position, 1) : L('not in the data', 'sin datos'), clicks_last7: r ? r.clicks : 0 });\n        }\n      }\n    }\n  }\n\n  // Google Analytics 4\n  if (dd.ga4) {\n    ran.ga4 = true;\n    const daily = ga4Rows(dd.ga4[0] || {});\n    const byDate = {};\n    for (const r of daily) {\n      const x = byDate[r.date] || (byDate[r.date] = { sessions: 0, organic: 0, events: 0 });\n      x.sessions += r.sessions;\n      x.events += r.keyEvents;\n      if (r.sessionDefaultChannelGroup === 'Organic Search') x.organic += r.sessions;\n    }\n    const dates = Object.keys(byDate).sort();\n    const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10).replace(/-/g, '');\n    const before = dates.filter(x => x < yesterday).slice(-28);\n    const avg = before.length ? before.reduce((a, k) => a + byDate[k].sessions, 0) / before.length : 0;\n    const yday = byDate[yesterday] ? byDate[yesterday].sessions : 0;\n    if (avg >= 10 && yday === 0) add('tracking_broken', 'critical', '', L('Google Analytics recorded no visits yesterday', 'Google Analytics no registró visitas ayer'), { yesterday_sessions: 0, daily_avg: Math.round(avg) });\n    const last7 = dates.slice(-7);\n    const prev28 = dates.slice(-35, -7);\n    const sumK = (keys, f) => keys.reduce((a, k) => a + byDate[k][f], 0);\n    const g = { organic: sumK(last7, 'organic'), organic_base: sumK(prev28, 'organic') / 4, events: sumK(last7, 'events'), events_base: sumK(prev28, 'events') / 4 };\n    if (kpi.gsc && kpi.gsc.recent.clicks >= 50 && kpi.gsc.base.clicks > 0) {\n      const ratioNow = g.organic / kpi.gsc.recent.clicks;\n      const ratioBefore = g.organic_base / kpi.gsc.base.clicks;\n      if (ratioNow < 0.2 && ratioBefore >= 0.5) add('tracking_partial', 'high', '', L('Analytics counts far fewer organic visits than Search Console clicks', 'Analytics cuenta muchas menos visitas orgánicas que los clics de Search Console'), { organic_sessions_last7: g.organic, search_clicks_last7: kpi.gsc.recent.clicks, ratio_now: round(ratioNow, 2), ratio_before: round(ratioBefore, 2) });\n    }\n    const ce = pct(g.events, g.events_base);\n    if (g.events_base >= 5 && ce !== null && ce <= -Number(s.conversion_drop_pct)) add('conversions_drop', 'high', '', L('Key events down ', 'Eventos clave bajaron un ') + Math.abs(ce) + '%', { last7: g.events, weekly_avg: round(g.events_base, 1), change_pct: ce });\n    const lp = ga4Rows(dd.ga4[1] || {});\n    const lpMap = {};\n    for (const r of lp) {\n      const x = lpMap[r.landingPage] || (lpMap[r.landingPage] = { recent: 0, prev: 0, sessions_recent: 0, sessions_prev: 0 });\n      if (r.dateRange === 'recent') { x.recent += r.keyEvents; x.sessions_recent += r.sessions; } else { x.prev += r.keyEvents; x.sessions_prev += r.sessions; }\n    }\n    Object.keys(lpMap).filter(k => lpMap[k].prev / 4 >= 3 && lpMap[k].recent === 0).slice(0, 5)\n      .forEach(k => add('landing_conversions_drop', 'medium', origin + k, L('No key events from this landing page this week', 'Esta página de entrada no generó eventos clave esta semana'), { key_events_last7: 0, weekly_avg: round(lpMap[k].prev / 4, 1), sessions_last7: lpMap[k].sessions_recent }));\n    const ai = ga4Rows(dd.ga4[2] || {});\n    const src = {};\n    for (const r of ai) {\n      const x = src[r.sessionSource] || (src[r.sessionSource] = { recent: 0, prev: 0 });\n      if (r.dateRange === 'recent') x.recent += r.sessions; else x.prev += r.sessions;\n    }\n    const aiRecent = Object.values(src).reduce((a, x) => a + x.recent, 0);\n    const aiBase = Object.values(src).reduce((a, x) => a + x.prev, 0) / 4;\n    const top = Object.keys(src).sort((a, b) => src[b].recent - src[a].recent).slice(0, 5).map(k => ({ source: k, last7: src[k].recent, weekly_avg: round(src[k].prev / 4, 1) }));\n    kpi.ga4 = Object.assign(g, { ai_recent: aiRecent, ai_base: aiBase, ai_sources: top });\n    const ca = pct(aiRecent, aiBase);\n    if (aiBase >= 10 && ca !== null && ca <= -50) add('ai_referrals_drop', 'medium', '', L('Visits from AI assistants down ', 'Visitas desde asistentes de IA bajaron un ') + Math.abs(ca) + '%', { last7: aiRecent, weekly_avg: round(aiBase, 1), sources: top });\n  }\n\n  // Page checks\n  const statesBy = {};\n  for (const st of states.filter(x => x.site_id === site.site_id)) statesBy[st.url] = st;\n  const pageRows = [];\n  for (const p of pages.filter(x => x.site_id === site.site_id)) {\n    const st = statesBy[p.url];\n    const fields = p.kind === 'robots' ? ['status', 'body_hash'] : ['status', 'title', 'description', 'canonical', 'robots', 'location'];\n    const diff = [];\n    if (st) for (const f of fields) if (String(st[f] == null ? '' : st[f]) !== String(p[f] == null ? '' : p[f])) diff.push({ field: f, from: String(st[f] == null ? '' : st[f]).slice(0, 200), to: String(p[f] == null ? '' : p[f]).slice(0, 200) });\n    if (diff.length && !p.error) for (const x of diff) changes.push(Object.assign({ url: p.url, date: today }, x));\n    pageRows.push({\n      site_id: site.site_id, url: p.url, status: p.status, title: p.title, description: p.description, canonical: p.canonical,\n      robots: p.robots, location: p.location, body_hash: p.body_hash, last_checked: today,\n      last_changed: diff.length ? today : ((st && st.last_changed) || today),\n      last_change: diff.length ? diff.map(x => x.field).join(', ') : ((st && st.last_change) || '')\n    });\n    if (p.kind === 'robots') {\n      ran.robots = true;\n      if (p.status >= 500 || (p.error && !p.status)) add('robots_unreachable', 'high', '', L('robots.txt does not load (Google may stop crawling)', 'robots.txt no carga (Google puede dejar de rastrear)'), { status: p.status, error: p.error });\n      if (p.status === 200) {\n        const groups = robotsGroups(p.robots_txt);\n        if (blocksAll(groups, 'googlebot')) add('robots_blocks_google', 'critical', '', L('robots.txt blocks Google from the whole site', 'robots.txt bloquea a Google en todo el sitio'), {});\n        const blocked = Object.keys(AI_BOTS).filter(b => blocksAll(groups, b)).map(b => AI_BOTS[b]);\n        if (blocked.length) add('ai_crawlers_blocked', 'medium', '', L('robots.txt blocks AI crawlers: ', 'robots.txt bloquea rastreadores de IA: ') + blocked.join(', '), { blocked: blocked });\n      }\n      continue;\n    }\n    ran.pages.add(p.url);\n    const isHome = norm(p.url) === norm(origin);\n    if (isHome && (p.error || !p.status || p.status >= 400)) { add('site_down', 'critical', p.url, L('The home page is not loading', 'La página de inicio no carga'), { status: p.status, error: p.error }); continue; }\n    if (p.error || !p.status) { add('http_error', 'high', p.url, L('A page with search traffic does not load', 'Una página con tráfico de búsqueda no carga'), { error: p.error }); continue; }\n    if (p.status >= 400) { add('http_error', p.status >= 500 ? 'critical' : 'high', p.url, L('A page with search traffic returns ', 'Una página con tráfico de búsqueda responde ') + p.status, { status: p.status }); continue; }\n    if (p.status >= 300) { if (!isHome) add('redirect', 'medium', p.url, L('A page with search traffic redirects', 'Una página con tráfico de búsqueda redirige'), { status: p.status, to: p.location }); continue; }\n    if (p.robots.includes('noindex')) add('noindex', 'critical', p.url, L('A page with search traffic is set to noindex', 'Una página con tráfico de búsqueda tiene noindex'), { robots: p.robots });\n    if (p.canonical && norm(p.canonical) !== norm(p.url)) add('canonical_elsewhere', 'medium', p.url, L('The page names another URL as canonical', 'La página declara otra URL como canónica'), { canonical: p.canonical });\n  }\n\n  // URL Inspection\n  for (const x of inspections.filter(i => i.site_id === site.site_id)) {\n    const r = x.result && x.result.inspectionResult && x.result.inspectionResult.indexStatusResult;\n    if (!r) continue;\n    ran.inspected.add(x.url);\n    const info = { verdict: r.verdict, coverage: r.coverageState, last_crawl: r.lastCrawlTime || '', robots: r.robotsTxtState, fetch: r.pageFetchState, google_canonical: r.googleCanonical || '', declared_canonical: r.userCanonical || '' };\n    for (const i of issues) if (i.page === x.url) i.detail.inspection = info;\n    // A page already flagged for noindex or an HTTP error is not reported again as not indexed.\n    const explained = has('noindex', x.url) || has('http_error', x.url) || has('redirect', x.url);\n    if (r.verdict && r.verdict !== 'PASS' && !explained) add('not_indexed', 'high', x.url, L('Google reports the page is not indexed: ', 'Google indica que la página no está indexada: ') + (r.coverageState || r.verdict), info);\n    if (r.verdict === 'PASS' && r.googleCanonical && r.userCanonical && norm(r.googleCanonical) !== norm(r.userCanonical)) add('google_canonical', 'medium', x.url, L('Google picked a different canonical than the page declares', 'Google eligió una URL canónica distinta a la que declara la página'), info);\n  }\n\n  // Issue history: new, worse, still open, resolved\n  issues.sort((a, b) => RANK[b.severity] - RANK[a.severity]);\n  const openSite = open.filter(o => o.site_id === site.site_id);\n  const openBy = {};\n  for (const o of openSite) openBy[o.issue_key] = o;\n  const found = new Set(issues.map(i => i.issue_key));\n  const GSC = ['traffic_drop', 'impressions_drop', 'page_drop', 'money_query_drop', 'gsc_error'];\n  const GA4 = ['tracking_broken', 'tracking_partial', 'conversions_drop', 'landing_conversions_drop', 'ai_referrals_drop', 'ga4_error'];\n  const PAGE = ['site_down', 'http_error', 'redirect', 'noindex', 'canonical_elsewhere'];\n  const ROBOTS = ['robots_unreachable', 'robots_blocks_google', 'ai_crawlers_blocked'];\n  const checked = o => {\n    if (o.type === 'gsc_error') return ran.gsc;\n    if (o.type === 'ga4_error') return ran.ga4 || !site.ga4_property;\n    if (GSC.includes(o.type)) return ran.gsc;\n    if (GA4.includes(o.type)) return ran.ga4;\n    if (PAGE.includes(o.type)) return ran.pages.has(o.page);\n    if (ROBOTS.includes(o.type)) return ran.robots;\n    if (o.type === 'not_indexed' || o.type === 'google_canonical') return ran.inspected.has(o.page);\n    return true;\n  };\n  // An issue that could not be checked for a week (for example a page that left the top pages) is closed.\n  const stale = o => String(o.last_seen || '') < d(7);\n  const newIssues = issues.filter(i => !openBy[i.issue_key]);\n  const worse = issues.filter(i => openBy[i.issue_key] && RANK[i.severity] > (RANK[openBy[i.issue_key].severity] || 0));\n  const ongoing = issues.filter(i => openBy[i.issue_key] && !worse.includes(i));\n  const resolved = openSite.filter(o => !found.has(o.issue_key) && (checked(o) || stale(o)));\n  const issueRows = issues.map(i => ({\n    site_id: site.site_id, issue_key: i.issue_key, type: i.type, severity: i.severity, page: i.page, label: i.label,\n    detail: JSON.stringify(i.detail).slice(0, 4000), first_seen: openBy[i.issue_key] ? openBy[i.issue_key].first_seen : today,\n    last_seen: today, status: 'open', resolved_at: ''\n  })).concat(resolved.map(o => ({\n    site_id: site.site_id, issue_key: o.issue_key, type: o.type, severity: o.severity, page: o.page, label: o.label,\n    detail: o.detail, first_seen: o.first_seen, last_seen: o.last_seen, status: 'resolved', resolved_at: today\n  })));\n  const firstRun = !states.some(x => x.site_id === site.site_id);\n  const criticalNew = newIssues.concat(worse).filter(i => i.severity === 'critical');\n  out.push({ json: {\n    site: site, date: today, kpi: kpi, errors: dd.errors || [],\n    new_issues: newIssues, worse_issues: worse, ongoing_issues: ongoing,\n    resolved_issues: resolved.map(o => ({ issue_key: o.issue_key, label: o.label, page: o.page, first_seen: o.first_seen })),\n    changes: changes, issue_rows: issueRows, page_rows: pageRows, critical_new: criticalNew, first_run: firstRun,\n    report_needed: Boolean(newIssues.length || worse.length || resolved.length || firstRun || site.digest === 'always')\n  } });\n}\nreturn out;"
      }
    },
    {
      "id": "803706ff-c2c0-49d7-b619-9b612f147c9b",
      "name": "Page State Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3584,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $input.all().flatMap(i => i.json.page_rows.map(r => ({ json: r })));"
      }
    },
    {
      "id": "bd05a329-b02a-47cf-8eae-39fd57f87333",
      "name": "Save Page States",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        3808,
        0
      ],
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_pages"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "site_id",
              "condition": "eq",
              "keyValue": "={{ $json.site_id }}"
            },
            {
              "keyName": "url",
              "condition": "eq",
              "keyValue": "={{ $json.url }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        }
      }
    },
    {
      "id": "82d1502d-dbc2-42d0-a375-168cdd19b807",
      "name": "Issue Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3584,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $('Find Issues').all().flatMap(i => i.json.issue_rows.map(r => ({ json: r })));"
      },
      "executeOnce": true
    },
    {
      "id": "dd5bb4f7-3b00-474c-9ffd-19985f8fd5a6",
      "name": "Save Issues",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        3808,
        192
      ],
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_issues"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "site_id",
              "condition": "eq",
              "keyValue": "={{ $json.site_id }}"
            },
            {
              "keyName": "issue_key",
              "condition": "eq",
              "keyValue": "={{ $json.issue_key }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        }
      }
    },
    {
      "id": "8cdc504b-d206-4d2b-9718-b1fedea0ea21",
      "name": "Site Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3584,
        384
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One item per site that has something to report and an email address.\nreturn $('Find Issues').all().filter(i => i.json.report_needed && i.json.site.report_email).map(i => ({ json: i.json }));"
      },
      "executeOnce": true
    },
    {
      "id": "927a86ea-6972-4198-b3a6-dfc916fa9562",
      "name": "Build Brief Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3808,
        384
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "// Zabio writes the explanation from the facts; it gets nothing it could invent from.\nconst s = $('SEO Settings').first().json;\nconst a = $json;\nconst NL = String.fromCharCode(10);\nconst LANGS = { en: 'English', es: 'Spanish', pt: 'Portuguese', fr: 'French', it: 'Italian', de: 'German' };\nconst slim = i => ({ issue_key: i.issue_key, severity: i.severity, page: i.page, label: i.label, facts: i.detail });\nconst facts = {\n  site: a.site.domain, date: a.date, metrics: a.kpi, data_errors: a.errors,\n  new_issues: a.new_issues.map(slim), worse_issues: a.worse_issues.map(slim),\n  resolved_issues: a.resolved_issues, still_open: a.ongoing_issues.map(i => ({ issue_key: i.issue_key, severity: i.severity, label: i.label })),\n  page_changes: a.changes.slice(0, 40), first_run: a.first_run\n};\nconst instructions = [\n  'You write the daily SEO and AI search report for a small business website. You receive facts computed from Google Search Console, Google Analytics 4, checks of the site pages and Google URL Inspection.',\n  'Write for the site owner: plain words, short sentences, no filler, no generic SEO advice.',\n  'For every new or worse issue, give what happened in one or two sentences, the most likely cause that the facts support (for example a title change, a noindex tag, a redirect, a robots.txt rule, an indexing problem or a lost ranking) and one concrete action. If the facts do not point to a cause, say the cause is unclear and what to check first. Never invent numbers, dates or causes.',\n  'Use page_changes to connect causes: a change on a page a few days before its clicks fell is worth naming. Search Console data runs about 3 days behind, so a drop can show up several days after a change.',\n  'If first_run is true, this is the first report: describe the current state rather than changes.',\n  'The overview has 2 or 3 sentences: the overall trend, the most important thing to do today, and good news if an issue was resolved.',\n  'Write in ' + (LANGS[a.site.language] || 'English') + '. Return JSON that matches the schema; items use the issue_key values you were given.'\n].join(NL);\nconst schema = {\n  type: 'object', additionalProperties: false, required: ['headline', 'overview', 'items'],\n  properties: {\n    headline: { type: 'string' },\n    overview: { type: 'string' },\n    items: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['issue_key', 'what_happened', 'likely_cause', 'action'],\n      properties: { issue_key: { type: 'string' }, what_happened: { type: 'string' }, likely_cause: { type: 'string' }, action: { type: 'string' } } } }\n  }\n};\nreturn { json: { site_id: a.site.site_id, body: {\n  model: s.zabio_model,\n  messages: [{ role: 'system', content: instructions }, { role: 'user', content: JSON.stringify(facts) }],\n  response_format: { type: 'json_schema', json_schema: { name: 'seo_daily_brief', strict: true, schema: schema } },\n  zabio: { session_id: 'seo-watch-' + a.site.site_id }\n} } };"
      }
    },
    {
      "id": "5a223ddd-d1d1-4abe-912d-7bcdf7728863",
      "name": "Write Brief With Zabio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4032,
        384
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.zabio.ai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body) }}",
        "options": {
          "timeout": 180000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "762679b5-bf32-4799-9048-4f060fc828c8",
      "name": "Render Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4256,
        384
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The email: numbers first, then what changed, with Zabio's explanation next to each issue.\nfunction esc(t) { return String(t == null ? '' : t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;'); }\nfunction fill(t, v) { return String(t).replace(/[{]([a-z_]+)[}]/g, (m, k) => (v[k] === undefined || v[k] === null ? '' : String(v[k]))); }\nconst TEXT = {\n  en: {\n    title: 'Daily SEO report', kpis: 'Last 7 days compared with the weekly average of the 4 weeks before', metric: 'Metric', last7: 'Last 7 days', avg: 'Weekly average', change: 'Change',\n    clicks: 'Search clicks', impressions: 'Search impressions', ctr: 'Click-through rate', position: 'Average position (lower is better)',\n    organic: 'Organic visits (Analytics)', events: 'Key events (Analytics)', ai: 'Visits from AI assistants (Analytics)', sources: 'AI sources this week',\n    new_h: 'New issues', worse_h: 'Getting worse', resolved_h: 'Resolved', open_h: 'Still open', changes_h: 'Changes spotted on your pages',\n    cause: 'Likely cause', action: 'What to do', nothing: 'No new issues today.', first: 'First report: this is the current state of the site. From tomorrow you only hear about changes.',\n    data_note: 'Search Console data runs about 3 days behind: search figures cover {from} to {to}.', errors: 'Could not read: {list}',\n    footer: 'Figures come from Search Console, Google Analytics and checks of your pages. Explanations are written by an AI model and can be wrong, so check before you act.',\n    cost: 'AI cost of this report: USD {cost}', subject: 'SEO report for {site}: {headline}', urgent: 'Urgent',\n    sev: { critical: 'Urgent', high: 'High', medium: 'Medium' }\n  },\n  es: {\n    title: 'Reporte SEO diario', kpis: 'Últimos 7 días frente al promedio semanal de las 4 semanas anteriores', metric: 'Métrica', last7: 'Últimos 7 días', avg: 'Promedio semanal', change: 'Cambio',\n    clicks: 'Clics en Google', impressions: 'Impresiones en Google', ctr: 'Tasa de clics', position: 'Posición promedio (menor es mejor)',\n    organic: 'Visitas orgánicas (Analytics)', events: 'Eventos clave (Analytics)', ai: 'Visitas desde asistentes de IA (Analytics)', sources: 'Fuentes de IA esta semana',\n    new_h: 'Problemas nuevos', worse_h: 'Empeorando', resolved_h: 'Resueltos', open_h: 'Siguen abiertos', changes_h: 'Cambios detectados en tus páginas',\n    cause: 'Causa probable', action: 'Qué hacer', nothing: 'Hoy no hay problemas nuevos.', first: 'Primer reporte: este es el estado actual del sitio. Desde mañana solo te avisamos de los cambios.',\n    data_note: 'Los datos de Search Console llevan unos 3 días de retraso: las cifras de búsqueda cubren del {from} al {to}.', errors: 'No se pudo leer: {list}',\n    footer: 'Las cifras vienen de Search Console, Google Analytics y revisiones de tus páginas. Las explicaciones las escribe un modelo de IA y pueden equivocarse: verifica antes de actuar.',\n    cost: 'Costo de IA de este reporte: USD {cost}', subject: 'Reporte SEO de {site}: {headline}', urgent: 'Urgente',\n    sev: { critical: 'Urgente', high: 'Alta', medium: 'Media' }\n  }\n};\nconst COLORS = { critical: '#b91c1c', high: '#c2410c', medium: '#a16207' };\nconst num = v => (v === null || v === undefined ? '-' : Number(v).toLocaleString('en-US', { maximumFractionDigits: 1 }));\nconst pctTxt = (a, b, lowerBetter) => {\n  if (a === null || a === undefined || !b) return '-';\n  const c = Math.round((a - b) / b * 1000) / 10;\n  const good = lowerBetter ? c < 0 : c > 0;\n  return '<span style=\"color:' + (c === 0 ? '#666' : good ? '#15803d' : '#b91c1c') + '\">' + (c > 0 ? '+' : '') + c + '%</span>';\n};\n// Without an AI explanation, show the facts as short key: value pairs.\nconst facts = d => Object.keys(d || {}).filter(k => d[k] !== null && d[k] !== '' && typeof d[k] !== 'object').map(k => k.replace(/_/g, ' ') + ': ' + d[k]).join(' · ');\nconst td = 'padding:6px 8px;border-bottom:1px solid #e5e7eb;';\nconst out = [];\n$input.all().forEach((item, i) => {\n  const a = $('Site Results').itemMatching(i).json;\n  const T = TEXT[a.site.language] || TEXT.en;\n  const j = item.json || {};\n  let brief = null;\n  try { brief = JSON.parse(j.choices[0].message.content); } catch (e) { brief = null; }\n  const notes = {};\n  for (const it of (brief && brief.items) || []) notes[it.issue_key] = it;\n  const cost = Number((j.zabio && j.zabio.cost_micros) || 0);\n  const h = [];\n  h.push('<div style=\"font-family:Arial,sans-serif;font-size:14px;color:#303030;max-width:760px\">');\n  h.push('<p style=\"margin:0;color:#666\">' + esc(T.title) + ' · ' + esc(a.site.domain) + ' · ' + esc(a.date) + '</p>');\n  h.push('<h2 style=\"margin:4px 0 8px\">' + esc((brief && brief.headline) || T.title) + '</h2>');\n  if (brief && brief.overview) h.push('<p>' + esc(brief.overview) + '</p>');\n  if (a.first_run) h.push('<p style=\"background:#eff6ff;padding:10px;border-radius:6px\">' + esc(T.first) + '</p>');\n  const k = a.kpi || {};\n  if (k.gsc || k.ga4) {\n    h.push('<h3>' + esc(T.kpis) + '</h3><table style=\"border-collapse:collapse;width:100%\"><tr><th align=\"left\" style=\"' + td + '\">' + T.metric + '</th><th align=\"right\" style=\"' + td + '\">' + T.last7 + '</th><th align=\"right\" style=\"' + td + '\">' + T.avg + '</th><th align=\"right\" style=\"' + td + '\">' + T.change + '</th></tr>');\n    const row = (label, a1, b1, fmt, lowerBetter) => h.push('<tr><td style=\"' + td + '\">' + esc(label) + '</td><td align=\"right\" style=\"' + td + '\">' + fmt(a1) + '</td><td align=\"right\" style=\"' + td + '\">' + fmt(b1) + '</td><td align=\"right\" style=\"' + td + '\">' + pctTxt(a1, b1, lowerBetter) + '</td></tr>');\n    if (k.gsc) {\n      row(T.clicks, k.gsc.recent.clicks, k.gsc.base.clicks, num);\n      row(T.impressions, k.gsc.recent.impressions, k.gsc.base.impressions, num);\n      row(T.ctr, k.gsc.recent.ctr, k.gsc.base.ctr, v => (Math.round(v * 1000) / 10) + '%');\n      row(T.position, k.gsc.recent.position, k.gsc.base.position, num, true);\n    }\n    if (k.ga4) {\n      row(T.organic, k.ga4.organic, k.ga4.organic_base, num);\n      row(T.events, k.ga4.events, k.ga4.events_base, num);\n      row(T.ai, k.ga4.ai_recent, k.ga4.ai_base, num);\n    }\n    h.push('</table>');\n    if (k.ga4 && k.ga4.ai_sources && k.ga4.ai_sources.length) h.push('<p style=\"color:#666;font-size:12px\">' + esc(T.sources) + ': ' + k.ga4.ai_sources.map(x => esc(x.source) + ' ' + x.last7).join(', ') + '</p>');\n    if (k.gsc) h.push('<p style=\"color:#666;font-size:12px\">' + esc(fill(T.data_note, { from: k.gsc.from, to: k.gsc.to })) + '</p>');\n  }\n  if (a.errors && a.errors.length) h.push('<p style=\"color:#b91c1c;font-size:12px\">' + esc(fill(T.errors, { list: a.errors.map(e => e.kind + ' (' + e.message + ')').join('; ') })) + '</p>');\n  const issueBlock = (title, list) => {\n    if (!list.length) return;\n    h.push('<h3>' + esc(title) + '</h3>');\n    for (const it of list) {\n      const n = notes[it.issue_key];\n      h.push('<div style=\"border-left:4px solid ' + (COLORS[it.severity] || '#666') + ';padding:6px 10px;margin:0 0 10px;background:#fafafa\">');\n      h.push('<p style=\"margin:0\"><b>' + esc(it.label) + '</b> <span style=\"color:' + (COLORS[it.severity] || '#666') + ';font-size:12px\">' + esc(T.sev[it.severity] || it.severity) + '</span></p>');\n      if (it.page) h.push('<p style=\"margin:2px 0;font-size:12px;color:#666\">' + esc(it.page) + '</p>');\n      if (n) {\n        h.push('<p style=\"margin:6px 0 0\">' + esc(n.what_happened) + '</p>');\n        h.push('<p style=\"margin:4px 0 0\"><b>' + esc(T.cause) + ':</b> ' + esc(n.likely_cause) + '</p>');\n        h.push('<p style=\"margin:4px 0 0\"><b>' + esc(T.action) + ':</b> ' + esc(n.action) + '</p>');\n      } else if (facts(it.detail)) {\n        h.push('<p style=\"margin:6px 0 0;font-size:12px;color:#666\">' + esc(facts(it.detail)) + '</p>');\n      }\n      h.push('</div>');\n    }\n  };\n  if (!a.new_issues.length && !a.worse_issues.length) h.push('<p>' + esc(T.nothing) + '</p>');\n  issueBlock(T.new_h, a.new_issues);\n  issueBlock(T.worse_h, a.worse_issues);\n  if (a.resolved_issues.length) h.push('<h3>' + esc(T.resolved_h) + '</h3><ul>' + a.resolved_issues.map(x => '<li>' + esc(x.label) + (x.page ? ' <span style=\"color:#666;font-size:12px\">' + esc(x.page) + '</span>' : '') + '</li>').join('') + '</ul>');\n  if (a.ongoing_issues.length) h.push('<h3>' + esc(T.open_h) + '</h3><ul>' + a.ongoing_issues.map(x => '<li>' + esc(x.label) + (x.page ? ' <span style=\"color:#666;font-size:12px\">' + esc(x.page) + '</span>' : '') + '</li>').join('') + '</ul>');\n  if (a.changes.length) h.push('<h3>' + esc(T.changes_h) + '</h3><ul>' + a.changes.slice(0, 25).map(c => '<li><span style=\"font-size:12px;color:#666\">' + esc(c.url) + '</span><br>' + esc(c.field) + ': \"' + esc(c.from) + '\" → \"' + esc(c.to) + '\"</li>').join('') + '</ul>');\n  h.push('<p style=\"color:#666;font-size:12px;margin-top:20px\">' + esc(T.footer) + (cost ? '<br>' + esc(fill(T.cost, { cost: (cost / 1000000).toFixed(4) })) : '') + '</p></div>');\n  const urgent = a.critical_new.length ? T.urgent + ': ' : '';\n  out.push({ json: {\n    to: a.site.report_email,\n    subject: urgent + fill(T.subject, { site: a.site.domain.replace(/^https?:[/][/]/, ''), headline: (brief && brief.headline) || (a.new_issues.length + ' / ' + a.resolved_issues.length) }),\n    html: h.join('')\n  } });\n});\nreturn out;"
      }
    },
    {
      "id": "f639333c-c1b8-4091-9602-90b6ca5d2d8c",
      "name": "Email Report",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        4480,
        384
      ],
      "parameters": {
        "resource": "email",
        "operation": "send",
        "fromEmail": "={{ $(\"SEO Settings\").first().json.email_from }}",
        "toEmail": "={{ $json.to }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "webhookId": "0caf7159-da07-4c42-9f91-3f391954c403"
    },
    {
      "id": "00346a12-e7cb-4a6e-ba2b-4bda3d093f62",
      "name": "Build Urgent Alerts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3584,
        864
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Urgent issues also go to the chat channels each site has set up. The email has the details.\nconst s = $('SEO Settings').first().json;\nconst out = [];\nfor (const item of $input.all()) {\n  const a = item.json;\n  if (!a.critical_new.length) continue;\n  const site = a.site;\n  const ES = site.language === 'es';\n  const domain = site.domain.replace(/^https?:[/][/]/, '').replace(/[/]$/, '');\n  const list = a.critical_new.map(i => i.label + (i.page ? ' (' + i.page + ')' : '')).join('; ').slice(0, 600);\n  const text = (ES ? 'Problema SEO urgente en ' : 'Urgent SEO issue on ') + domain + ': ' + list + (ES ? '. El reporte completo está en tu correo.' : '. The full report is in your email.');\n  if (site.alert_whatsapp && s.wa_template && s.wa_phone_number_id) out.push({ json: { channel: 'whatsapp', to: String(site.alert_whatsapp).replace(/[^0-9]/g, ''), domain: domain, summary: list, text: text } });\n  if (site.slack_webhook) out.push({ json: { channel: 'slack', url: site.slack_webhook, text: text } });\n  if (site.discord_webhook) out.push({ json: { channel: 'discord', url: site.discord_webhook, text: text } });\n  if (site.telegram_chat_id) out.push({ json: { channel: 'telegram', chat_id: String(site.telegram_chat_id), text: text } });\n}\nreturn out;"
      }
    },
    {
      "id": "99de33b3-da55-4a14-9597-f021c8106819",
      "name": "Alert Channel",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        3808,
        832
      ],
      "parameters": {
        "mode": "rules",
        "rules": {
          "values": [
            {
              "outputKey": "WhatsApp",
              "renameOutput": true,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.channel }}",
                    "rightValue": "whatsapp",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "outputKey": "Slack",
              "renameOutput": true,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.channel }}",
                    "rightValue": "slack",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "outputKey": "Discord",
              "renameOutput": true,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.channel }}",
                    "rightValue": "discord",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            },
            {
              "outputKey": "Telegram",
              "renameOutput": true,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.channel }}",
                    "rightValue": "telegram",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              }
            }
          ]
        }
      }
    },
    {
      "id": "52392363-353c-4da7-8d6b-1c6c584c7419",
      "name": "WhatsApp Alert",
      "type": "n8n-nodes-base.whatsApp",
      "typeVersion": 1.1,
      "position": [
        4032,
        576
      ],
      "parameters": {
        "resource": "message",
        "operation": "sendTemplate",
        "phoneNumberId": "={{ $(\"SEO Settings\").first().json.wa_phone_number_id }}",
        "recipientPhoneNumber": "={{ $json.to }}",
        "template": "={{ $(\"SEO Settings\").first().json.wa_template }}",
        "components": {
          "component": [
            {
              "type": "body",
              "bodyParameters": {
                "parameter": [
                  {
                    "type": "text",
                    "text": "={{ $json.domain }}"
                  },
                  {
                    "type": "text",
                    "text": "={{ $json.summary }}"
                  }
                ]
              }
            }
          ]
        }
      },
      "webhookId": "16eb598d-e50d-4774-983f-1b367009c6a0",
      "onError": "continueRegularOutput"
    },
    {
      "id": "97d76958-62ec-4456-899c-b0e5cf218fc7",
      "name": "Slack Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4032,
        768
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.url }}",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.text }) }}",
        "options": {
          "timeout": 20000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "18045271-fcac-49be-869a-82cfce776021",
      "name": "Discord Alert",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4032,
        960
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $json.url }}",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ content: $json.text }) }}",
        "options": {
          "timeout": 20000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "45ede618-0620-493f-b401-9e4b14fe9aa7",
      "name": "Telegram Alert",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        4032,
        1152
      ],
      "parameters": {
        "resource": "message",
        "operation": "sendMessage",
        "chatId": "={{ $json.chat_id }}",
        "text": "={{ $json.text }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "webhookId": "9444feba-c4a4-4083-9820-998757c2b01a",
      "onError": "continueRegularOutput"
    },
    {
      "id": "3b472e5f-4020-4a2d-8025-206ed5408a70",
      "name": "Sticky Note: Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -80
      ],
      "parameters": {
        "content": "## Daily SEO watch with Zabio\nEvery morning, for every site in the seo_sites table: Search Console and Google Analytics 4 numbers, checks of the home page, robots.txt and the top pages, and Google URL Inspection on pages that need a look. Plain code finds the issues and keeps their history in seo_issues; Zabio explains them in a short report.\n\nYou get an email only when something is new, worse or fixed. Urgent issues also go to WhatsApp, Slack, Discord or Telegram.\n\nSetup guide: https://remarkablecloud.com/docs/automation/seo-geo-daily-watch/",
        "color": 4,
        "height": 340,
        "width": 660
      }
    },
    {
      "id": "698def99-e96f-4c60-ad90-3f5a32b05167",
      "name": "Sticky Note: Google data",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        640,
        -80
      ],
      "parameters": {
        "content": "### Google data\nOne Google OAuth2 credential with the scopes webmasters.readonly and analytics.readonly reads every site the account can see. Search Console data is final about 3 days later, so the last 7 days end 3 days ago and are compared with the 28 days before. GA4 is optional per site: leave ga4_property empty to skip it.",
        "color": 5,
        "height": 340,
        "width": 660
      }
    },
    {
      "id": "f9402d54-d429-4a26-ae57-dce92bf937b3",
      "name": "Sticky Note: Pages",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1320,
        -80
      ],
      "parameters": {
        "content": "### Page checks\nStatus codes, redirects, noindex, canonical, title and description of the home page and the top pages by clicks, plus robots.txt rules for Googlebot and AI crawlers. The last values seen live in seo_pages, so changes show up in the report. URL Inspection runs only on pages that lost clicks or look wrong.",
        "color": 6,
        "height": 280,
        "width": 1560
      }
    },
    {
      "id": "cc449a49-bb3c-491d-8016-7ae4326caa2f",
      "name": "Sticky Note: Alerts",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4300,
        600
      ],
      "parameters": {
        "content": "### Urgent alerts\nSite down, noindex on a top page, robots.txt blocking Google or Analytics recording no visits. Set alert_whatsapp, slack_webhook, discord_webhook or telegram_chat_id per site. WhatsApp needs an approved template with two body variables (site, issue) in wa_template, for example seo_alert|en.",
        "color": 3,
        "height": 320,
        "width": 520
      }
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}