{
  "name": "Weekly SEO Opportunities and AI Visibility (Zabio)",
  "connections": {
    "Every Monday": {
      "main": [
        [
          {
            "node": "Weekly Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Settings": {
      "main": [
        [
          {
            "node": "Monday Run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 2 Hours": {
      "main": [
        [
          {
            "node": "Weekly Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Monday Run?": {
      "main": [
        [
          {
            "node": "Load Sites",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Pending Weeks",
            "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": "Find Opportunities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Opportunities": {
      "main": [
        [
          {
            "node": "Pages To Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pages To Read": {
      "main": [
        [
          {
            "node": "Fetch Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Pages": {
      "main": [
        [
          {
            "node": "Read Pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Pages": {
      "main": [
        [
          {
            "node": "Build Zabio Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Zabio Jobs": {
      "main": [
        [
          {
            "node": "Submit Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit Jobs": {
      "main": [
        [
          {
            "node": "Week Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Week Rows": {
      "main": [
        [
          {
            "node": "Save Week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Pending Weeks": {
      "main": [
        [
          {
            "node": "Load Sites For Reports",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Sites For Reports": {
      "main": [
        [
          {
            "node": "Expand Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand Jobs": {
      "main": [
        [
          {
            "node": "Check Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Jobs": {
      "main": [
        [
          {
            "node": "Gather Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gather Results": {
      "main": [
        [
          {
            "node": "Visibility Rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Load Visibility History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Visibility Rows": {
      "main": [
        [
          {
            "node": "Save Visibility",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Visibility History": {
      "main": [
        [
          {
            "node": "Render Weekly Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Weekly Report": {
      "main": [
        [
          {
            "node": "Email Weekly Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Email Weekly Report": {
      "main": [
        [
          {
            "node": "Mark Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "nodes": [
    {
      "id": "3dbb6149-aec7-4515-a16b-90cdb7400e8e",
      "name": "Every Monday",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        96
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 6,
              "triggerAtMinute": 0
            }
          ]
        }
      }
    },
    {
      "id": "69b6137f-52c5-4a4a-9e0d-fa6f3a371ec9",
      "name": "Weekly Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        224,
        192
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "ws-from",
              "name": "email_from",
              "value": "SEO Watch <seo@example.com>",
              "type": "string"
            },
            {
              "id": "ws-model",
              "name": "review_model",
              "value": "auto/best",
              "type": "string"
            },
            {
              "id": "ws-deadline",
              "name": "deadline",
              "value": "24h",
              "type": "string"
            },
            {
              "id": "ws-vis",
              "name": "visibility_models",
              "value": "[\"gpt-5-mini\", \"claude-haiku-5-5\", \"gemini-3.5-flash-lite\", \"deepseek-v4-flash@deepinfra\", \"mistral-small-2603\"]",
              "type": "string"
            },
            {
              "id": "ws-smin",
              "name": "striking_min_position",
              "value": 4,
              "type": "number"
            },
            {
              "id": "ws-smax",
              "name": "striking_max_position",
              "value": 15,
              "type": "number"
            },
            {
              "id": "ws-imp",
              "name": "min_impressions",
              "value": 50,
              "type": "number"
            },
            {
              "id": "ws-pages",
              "name": "max_pages",
              "value": 5,
              "type": "number"
            },
            {
              "id": "ws-gap",
              "name": "gap_min_impressions",
              "value": 30,
              "type": "number"
            },
            {
              "id": "ws-ai",
              "name": "ai_sources",
              "value": "chatgpt|openai|perplexity|gemini|bard|copilot|claude|anthropic|deepseek|meta.ai|you.com|phind|grok|mistral",
              "type": "string"
            },
            {
              "id": "ws-ua",
              "name": "user_agent",
              "value": "Mozilla/5.0 (compatible; SEOWatch/1.0; n8n template)",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "d0fc981c-4083-4501-b5a2-ee0f047c82df",
      "name": "Every 2 Hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        288
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 2
            }
          ]
        }
      }
    },
    {
      "id": "e252895f-3997-466b-98a7-9c2adc21a0b6",
      "name": "Monday Run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        448,
        192
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "leftValue": "={{ $(\"Every Monday\").isExecuted }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      }
    },
    {
      "id": "e66f8166-c77f-4d68-8948-9737e4bb4d0d",
      "name": "Load Sites",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        672,
        0
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_sites"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "active",
              "condition": "isTrue"
            }
          ]
        },
        "returnAll": true
      }
    },
    {
      "id": "2c2c50d2-ef50-4c0c-94ec-6ce850eda876",
      "name": "Build Google Requests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        896,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// 28 days of query and page data from Search Console, and 8 weeks of AI assistant visits from GA4.\nconst s = $('Weekly 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    out.push({ json: { site_id: site.site_id, kind: 'gsc_qp', url: url, body: { startDate: d(30), endDate: d(3), dimensions: ['query', 'page'], rowLimit: 5000 } } });\n    out.push({ json: { site_id: site.site_id, kind: 'gsc_pages', url: url, body: { startDate: d(30), endDate: d(3), dimensions: ['page'], rowLimit: 1000 } } });\n  }\n  if (site.ga4_property) {\n    const prop = String(site.ga4_property).replace(/[^0-9]/g, '');\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: '56daysAgo', endDate: 'yesterday' }], dimensions: [{ name: 'isoYearIsoWeek' }, { name: 'sessionSource' }], metrics: [{ name: 'sessions' }, { name: 'keyEvents' }], dimensionFilter: { filter: { fieldName: 'sessionSource', stringFilter: { matchType: 'PARTIAL_REGEXP', value: s.ai_sources, caseSensitive: false } } }, limit: 1000 }\n    ] } } });\n  }\n}\nreturn out;"
      }
    },
    {
      "id": "04ed1a2d-dd97-4ca5-9985-0fc9b58d38f2",
      "name": "Call Google APIs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        0
      ],
      "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": "c7736b45-e9fa-41cf-b40a-e98ee01f0e24",
      "name": "Find Opportunities",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1344,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Plain arithmetic finds the opportunities; Zabio only writes the fixes later.\nconst s = $('Weekly Settings').first().json;\nconst sites = $('Load Sites').all().map(i => i.json).filter(x => x.site_id && x.domain);\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] = { errors: [] });\n  const j = item.json || {};\n  if (j.error) { d.errors.push({ kind: r.kind, message: String(j.error.message || JSON.stringify(j.error)).slice(0, 300) }); return; }\n  if (r.kind === 'ga4') { d.ga4 = j.reports || []; return; }\n  d[r.kind] = j.rows || [];\n});\n// Rough click-through rates by position, used only to spot titles that underperform.\nconst EXP = [0, 0.28, 0.16, 0.11, 0.08, 0.06, 0.05, 0.04, 0.035, 0.03, 0.025];\nconst expCtr = p => { const k = Math.max(1, Math.round(p)); return k <= 10 ? EXP[k] : 0.01; };\nconst r1 = v => Math.round(v * 10) / 10;\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 dd = data[site.site_id] || { errors: [] };\n  const qp = (dd.gsc_qp || []).map(x => ({ query: x.keys[0], page: x.keys[1], clicks: x.clicks, impressions: x.impressions, ctr: x.ctr, position: x.position }));\n  const pagesRows = (dd.gsc_pages || []).map(x => ({ page: x.keys[0], clicks: x.clicks, impressions: x.impressions, ctr: x.ctr, position: x.position }));\n  const byPage = {};\n  for (const r of qp) (byPage[r.page] || (byPage[r.page] = [])).push(r);\n  // Striking distance: queries just below the top results.\n  const striking = {};\n  for (const r of qp) {\n    if (r.position >= Number(s.striking_min_position) && r.position <= Number(s.striking_max_position) && r.impressions >= Number(s.min_impressions)) {\n      striking[r.page] = (striking[r.page] || 0) + r.impressions;\n    }\n  }\n  const strikingPages = Object.keys(striking).sort((a, b) => striking[b] - striking[a]);\n  // Low CTR for the position.\n  const lowCtr = pagesRows.filter(p => p.impressions >= 200 && p.position <= 10 && p.ctr < 0.5 * expCtr(p.position)).sort((a, b) => b.impressions - a.impressions).map(p => p.page);\n  const picked = [];\n  for (const u of strikingPages.concat(lowCtr)) if (!picked.includes(u) && picked.length < Number(s.max_pages)) picked.push(u);\n  const pages = picked.map(u => {\n    const pr = pagesRows.find(p => p.page === u) || {};\n    return {\n      url: u,\n      reason: strikingPages.includes(u) ? 'striking_distance' : 'low_ctr',\n      clicks: pr.clicks || 0, impressions: pr.impressions || 0, ctr: r1((pr.ctr || 0) * 100), position: r1(pr.position || 0),\n      expected_ctr: r1(expCtr(pr.position || 10) * 100),\n      queries: (byPage[u] || []).sort((a, b) => b.impressions - a.impressions).slice(0, 8).map(q => ({ query: q.query, impressions: q.impressions, clicks: q.clicks, position: r1(q.position) }))\n    };\n  });\n  // Content gaps: queries people search where the site ranks poorly with every page.\n  const byQuery = {};\n  for (const r of qp) (byQuery[r.query] || (byQuery[r.query] = [])).push(r);\n  const gaps = Object.keys(byQuery).map(q => {\n    const rows = byQuery[q];\n    return { query: q, impressions: rows.reduce((a, x) => a + x.impressions, 0), best_position: r1(Math.min.apply(null, rows.map(x => x.position))), page: rows.slice().sort((a, b) => a.position - b.position)[0].page };\n  }).filter(g => g.impressions >= Number(s.gap_min_impressions) && g.best_position > 20).sort((a, b) => b.impressions - a.impressions).slice(0, 15);\n  // Cannibalization: two or more pages splitting the same query.\n  const cannibal = Object.keys(byQuery).map(q => {\n    const rows = byQuery[q];\n    const total = rows.reduce((a, x) => a + x.impressions, 0);\n    const split = rows.filter(x => total && x.impressions / total >= 0.2);\n    return { query: q, impressions: total, pages: split.map(x => ({ page: x.page, share: Math.round(x.impressions / total * 100), position: r1(x.position) })) };\n  }).filter(c => c.pages.length >= 2 && c.impressions >= Number(s.min_impressions)).sort((a, b) => b.impressions - a.impressions).slice(0, 8);\n  // GA4: visits from AI assistants per ISO week.\n  const weeks = {};\n  const sources = {};\n  for (const rep of dd.ga4 || []) {\n    const dims = (rep.dimensionHeaders || []).map(h => h.name);\n    for (const row of rep.rows || []) {\n      const o = {};\n      (row.dimensionValues || []).forEach((v, k) => { o[dims[k]] = v.value; });\n      const sessions = Number((row.metricValues || [])[0] && row.metricValues[0].value) || 0;\n      weeks[o.isoYearIsoWeek] = (weeks[o.isoYearIsoWeek] || 0) + sessions;\n      sources[o.sessionSource] = (sources[o.sessionSource] || 0) + sessions;\n    }\n  }\n  const aiWeeks = Object.keys(weeks).sort().map(w => ({ week: w, sessions: weeks[w] }));\n  const aiSources = Object.keys(sources).sort((a, b) => sources[b] - sources[a]).slice(0, 6).map(k => ({ source: k, sessions_8_weeks: sources[k] }));\n  const sitePages = pagesRows.slice().sort((a, b) => b.clicks - a.clicks).slice(0, 40).map(p => p.page);\n  out.push({ json: { site_id: site.site_id, origin: origin, errors: dd.errors, has_gsc: Boolean(dd.gsc_qp), pages: pages, gaps: gaps, cannibalization: cannibal, ai_weeks: aiWeeks, ai_sources: aiSources, site_pages: sitePages } });\n}\nreturn out;"
      }
    },
    {
      "id": "3e681762-eabd-4866-abc9-f44d36deb293",
      "name": "Pages To Read",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1568,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The home page is always read, so every site reaches the next steps.\nconst out = [];\nfor (const i of $input.all()) {\n  const urls = [i.json.origin + '/'].concat(i.json.pages.map(p => p.url));\n  for (const u of urls.filter((x, k) => urls.indexOf(x) === k)) out.push({ json: { site_id: i.json.site_id, url: u } });\n}\nreturn out;"
      }
    },
    {
      "id": "18173fb8-5692-4aa3-b551-b09de5e6dffa",
      "name": "Fetch Pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1792,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "={{ $(\"Weekly Settings\").first().json.user_agent }}"
            }
          ]
        },
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          },
          "batching": {
            "batch": {
              "batchSize": 5,
              "batchInterval": 300
            }
          }
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "900097a6-25f2-43ec-bc0b-ffbc19f52474",
      "name": "Read Pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2016,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// A compact summary of each page for the review: headings, opening text, FAQ and schema.\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, '>').replace(/&nbsp;/g, ' ');\nconst strip = t => decode(String(t || '').replace(/<[^>]+>/g, ' ')).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] : ''; };\nconst out = [];\n$input.all().forEach((item, i) => {\n  const req = $('Pages To Read').itemMatching(i).json;\n  const j = item.json || {};\n  let html = j.body !== undefined ? j.body : (j.data !== undefined ? j.data : '');\n  if (typeof html !== 'string') html = '';\n  html = html.slice(0, 400000);\n  const t = html.match(/<title[^>]*>([^<]*)<[/]title>/i);\n  let description = '';\n  for (const tag of html.match(/<meta[^>]+>/gi) || []) if (attr(tag, 'name').toLowerCase() === 'description') description = attr(tag, 'content');\n  const types = [];\n  for (const m of html.match(/\"@type\"[ ]*:[ ]*\"[^\"]+\"/g) || []) { const v = m.split('\"')[3]; if (!types.includes(v)) types.push(v); }\n  const body = html.replace(/<script[^>]*>[^]*?<[/]script>/gi, ' ').replace(/<style[^>]*>[^]*?<[/]style>/gi, ' ').replace(/<nav[^>]*>[^]*?<[/]nav>/gi, ' ').replace(/<footer[^>]*>[^]*?<[/]footer>/gi, ' ');\n  const heads = tag => (body.match(new RegExp('<' + tag + '[^>]*>[^]*?<[/]' + tag + '>', 'gi')) || []).map(strip).filter(Boolean);\n  const text = strip(body);\n  out.push({ json: {\n    site_id: req.site_id, url: req.url, status: Number(j.statusCode || 0),\n    title: strip(t ? t[1] : ''), description: strip(description),\n    h1: heads('h1').slice(0, 3), h2: heads('h2').slice(0, 15),\n    words: text ? text.split(' ').length : 0, schema_types: types,\n    has_faq: types.includes('FAQPage') || /faq|preguntas frecuentes/i.test(text),\n    opening_text: text.slice(0, 1500)\n  } });\n});\nreturn out;"
      }
    },
    {
      "id": "55af0d54-078b-4723-8928-3d9a21e09ee8",
      "name": "Build Zabio Jobs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2240,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One review per site, plus one answer per AI prompt and model. All of them run in the background.\nconst s = $('Weekly Settings').first().json;\nconst sites = $('Load Sites').all().map(i => i.json).filter(x => x.site_id && x.domain);\nconst opps = {};\nfor (const i of $('Find Opportunities').all()) opps[i.json.site_id] = i.json;\nconst pages = $input.all().map(i => i.json);\nconst NL = String.fromCharCode(10);\nconst LANGS = { en: 'English', es: 'Spanish', pt: 'Portuguese', fr: 'French', it: 'Italian', de: 'German' };\nconst parse = (v, d) => { try { const x = JSON.parse(v); return x == null ? d : x; } catch (e) { return d; } };\nconst now = new Date();\nconst week = new Date(Date.now() - ((now.getUTCDay() + 6) % 7) * 86400000).toISOString().slice(0, 10);\nconst models = parse(s.visibility_models, []);\nconst schema = {\n  type: 'object', additionalProperties: false, required: ['headline', 'overview', 'pages', 'gaps', 'cannibalization'],\n  properties: {\n    headline: { type: 'string' },\n    overview: { type: 'string' },\n    pages: { type: 'array', items: { type: 'object', additionalProperties: false,\n      required: ['url', 'why', 'new_title', 'new_description', 'answer_first', 'missing_questions', 'faq', 'schema_suggestion', 'internal_links_from'],\n      properties: {\n        url: { type: 'string' }, why: { type: 'string' }, new_title: { type: 'string' }, new_description: { type: 'string' }, answer_first: { type: 'string' },\n        missing_questions: { type: 'array', items: { type: 'string' } },\n        faq: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['q', 'a'], properties: { q: { type: 'string' }, a: { type: 'string' } } } },\n        schema_suggestion: { type: 'string' },\n        internal_links_from: { type: 'array', items: { type: 'string' } }\n      } } },\n    gaps: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['topic', 'queries', 'brief'], properties: { topic: { type: 'string' }, queries: { type: 'array', items: { type: 'string' } }, brief: { type: 'string' } } } },\n    cannibalization: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['query', 'recommendation'], properties: { query: { type: 'string' }, recommendation: { type: 'string' } } } }\n  }\n};\nconst out = [];\nfor (const site of sites) {\n  const o = opps[site.site_id];\n  if (!o) continue;\n  const lang = LANGS[site.language] || 'English';\n  const sitePages = pages.filter(p => p.site_id === site.site_id);\n  if (o.has_gsc && (o.pages.length || o.gaps.length || o.cannibalization.length)) {\n    const instructions = [\n      'You are the SEO and AI search editor for a small business website. You receive opportunities found in Google Search Console data and a summary of each page. Write concrete fixes, not general advice.',\n      'For each page in pages: why (one sentence on the opportunity, using its numbers), new_title (under 60 characters, built around the main queries, no keyword stuffing), new_description (under 155 characters, a reason to click), answer_first (2 or 3 sentences for the top of the page that answer the main query directly, so search engines and AI assistants can quote it), missing_questions (questions from the queries that the page does not answer yet), faq (up to 4 short question and answer pairs), schema_suggestion (which schema.org type to add, or none) and internal_links_from (up to 3 URLs from site_pages that should link to this page).',\n      'Use only facts that appear in the page summary. When a fact is missing, such as a price or a number, write a placeholder in square brackets for the owner to fill in, like [your price]. Never invent facts about the business.',\n      'For gaps, group related queries into at most 3 topics and write a short brief for a new page on each. For cannibalization, recommend which page should own each query and what to do with the other.',\n      'The headline names the single most valuable change. The overview has 2 or 3 sentences.',\n      'Write in ' + lang + '. Return JSON that matches the schema.'\n    ].join(NL);\n    const facts = { site: site.domain, opportunities: o.pages, gaps: o.gaps, cannibalization: o.cannibalization, page_summaries: sitePages.filter(p => p.status === 200), site_pages: o.site_pages };\n    out.push({ json: { site_id: site.site_id, week: week, kind: 'review', key: 'review', model: s.review_model, prompt: '', body: {\n      model: s.review_model, background: true, store: true, instructions: instructions, input: JSON.stringify(facts),\n      text: { format: { type: 'json_schema', name: 'seo_weekly_review', strict: true, schema: schema } },\n      zabio: { deadline: s.deadline, session_id: 'seo-weekly-' + site.site_id }\n    } } });\n  }\n  const prompts = parse(site.ai_prompts, []);\n  prompts.forEach((p, k) => {\n    for (const m of models) {\n      out.push({ json: { site_id: site.site_id, week: week, kind: 'visibility', key: m + '|' + k, model: m, prompt: p, body: {\n        model: m, background: true, store: true,\n        instructions: 'Answer the question as you normally would for someone asking you directly. Be specific: when products, services or companies are relevant, name them.',\n        input: p, zabio: { deadline: s.deadline, session_id: 'seo-visibility-' + site.site_id }\n      } } });\n    }\n  });\n}\nreturn out;"
      }
    },
    {
      "id": "524d5ff7-c718-484c-bfe9-bfd2af753d84",
      "name": "Submit Jobs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2464,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.zabio.ai/v1/responses",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body) }}",
        "options": {
          "timeout": 60000,
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 200
            }
          }
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "f61baa9b-f87d-4e2a-906e-12a2eb47af38",
      "name": "Week Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2688,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One row per site and week: the job ids to collect later and the facts for the report.\nconst opps = {};\nfor (const i of $('Find Opportunities').all()) opps[i.json.site_id] = i.json;\nconst pages = $('Read Pages').all().map(i => i.json);\nconst rows = {};\n$input.all().forEach((item, i) => {\n  const req = $('Build Zabio Jobs').itemMatching(i).json;\n  const r = rows[req.site_id] || (rows[req.site_id] = { site_id: req.site_id, week: req.week, jobs: [] });\n  const j = item.json || {};\n  r.jobs.push({ kind: req.kind, key: req.key, model: req.model, prompt: req.prompt, id: j.id || '', error: j.error ? String(j.error.message || JSON.stringify(j.error)).slice(0, 200) : '' });\n});\nconst now = new Date().toISOString();\nreturn Object.values(rows).map(r => {\n  const o = opps[r.site_id] || {};\n  const facts = { opportunities: o.pages || [], gaps: o.gaps || [], cannibalization: o.cannibalization || [], ai_weeks: o.ai_weeks || [], ai_sources: o.ai_sources || [], errors: o.errors || [],\n    page_titles: pages.filter(p => p.site_id === r.site_id).map(p => ({ url: p.url, title: p.title, description: p.description })) };\n  return { json: { site_id: r.site_id, week: r.week, status: 'pending', jobs: JSON.stringify(r.jobs), facts: JSON.stringify(facts), created_at: now, sent_at: '' } };\n});"
      }
    },
    {
      "id": "be085dd1-2f35-4e53-8344-3d5aaa036a94",
      "name": "Save Week",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2912,
        0
      ],
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_weekly"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "site_id",
              "condition": "eq",
              "keyValue": "={{ $json.site_id }}"
            },
            {
              "keyName": "week",
              "condition": "eq",
              "keyValue": "={{ $json.week }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        }
      }
    },
    {
      "id": "baf35a6f-a3e9-49ee-aa81-1ba265a90e0e",
      "name": "Load Pending Weeks",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        672,
        288
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_weekly"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "status",
              "condition": "eq",
              "keyValue": "pending"
            }
          ]
        },
        "returnAll": true
      }
    },
    {
      "id": "550101ee-43fc-44c9-89be-af8dc116a3e4",
      "name": "Load Sites For Reports",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        896,
        288
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_sites"
        },
        "returnAll": true
      },
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "id": "7f058d41-6306-4483-868d-9985528bb5ee",
      "name": "Expand Jobs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One item per Zabio job to check. A week with no job ids still gets one item so it can be reported.\nconst parse = (v, d) => { try { const x = JSON.parse(v); return x == null ? d : x; } catch (e) { return d; } };\nconst out = [];\nfor (const i of $('Load Pending Weeks').all()) {\n  const w = i.json;\n  const jobs = parse(w.jobs, []);\n  const withId = jobs.filter(j => j.id);\n  if (!withId.length) out.push({ json: { row_id: w.id, id: 'none', key: '' } });\n  for (const j of withId) out.push({ json: { row_id: w.id, id: j.id, key: j.key } });\n}\nreturn out;"
      }
    },
    {
      "id": "7b0cf255-a873-4a6d-8400-7b4a4c5c5f02",
      "name": "Check Jobs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1344,
        288
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://api.zabio.ai/v1/responses/{{ $json.id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 60000,
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 200
            }
          }
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "fb06b704-6fe7-40eb-943f-042547365dab",
      "name": "Gather Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1568,
        288
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// A week is reported once every job has finished, or after 30 hours with what is there.\nconst parse = (v, d) => { try { const x = JSON.parse(v); return x == null ? d : x; } catch (e) { return d; } };\nconst sites = {};\nfor (const i of $('Load Sites For Reports').all()) if (i.json.site_id) sites[i.json.site_id] = i.json;\nconst weeks = {};\nfor (const i of $('Load Pending Weeks').all()) weeks[i.json.id] = i.json;\nconst results = {};\n$input.all().forEach((item, k) => {\n  const req = $('Expand Jobs').itemMatching(k).json;\n  (results[req.row_id] || (results[req.row_id] = {}))[req.key] = item.json || {};\n});\nconst DONE = ['completed', 'incomplete', 'failed', 'cancelled'];\nconst textOf = r => { let t = ''; for (const o of (r.output || [])) if (o.type === 'message') for (const c of (o.content || [])) if (c.type === 'output_text') t += c.text; return t; };\nconst out = [];\nfor (const id of Object.keys(weeks)) {\n  const w = weeks[id];\n  const site = sites[w.site_id];\n  if (!site) continue;\n  const jobs = parse(w.jobs, []);\n  const res = results[id] || {};\n  const finished = j => !j.id || DONE.includes((res[j.key] || {}).status) || (res[j.key] || {}).error;\n  const old = Date.now() - new Date(w.created_at).getTime() > 30 * 3600 * 1000;\n  if (!jobs.every(finished) && !old) continue;\n  let cost = 0;\n  let review = null;\n  const brands = String(site.brand_names || '').split(',').map(x => x.trim()).filter(Boolean);\n  const competitors = parse(site.competitors, []);\n  const visibility = [];\n  for (const j of jobs) {\n    const r = res[j.key] || {};\n    cost += Number((r.zabio && r.zabio.cost_micros) || 0);\n    const text = r.status === 'completed' ? textOf(r) : '';\n    if (j.kind === 'review') { try { review = JSON.parse(text); } catch (e) { review = null; } continue; }\n    const low = text.toLowerCase();\n    const found = brands.concat(competitors).map(n => ({ name: n, at: low.indexOf(String(n).toLowerCase()) })).filter(x => x.at >= 0).sort((a, b) => a.at - b.at);\n    const mine = found.filter(x => brands.includes(x.name));\n    const at = mine.length ? mine[0].at : -1;\n    const ranked = [];\n    for (const f of found) { const label = brands.includes(f.name) ? '__brand__' : f.name; if (!ranked.includes(label)) ranked.push(label); }\n    visibility.push({\n      model: j.model, prompt: j.prompt, answered: Boolean(text),\n      mentioned: at >= 0, position: at >= 0 ? ranked.indexOf('__brand__') + 1 : null,\n      competitors: found.filter(x => !brands.includes(x.name)).map(x => x.name).filter((x, k, a) => a.indexOf(x) === k),\n      snippet: at >= 0 ? text.slice(Math.max(0, at - 140), at + 220).replace(/[*#]/g, '') : ''\n    });\n  }\n  out.push({ json: { row_id: w.id, site: site, week: w.week, facts: parse(w.facts, {}), review: review, visibility: visibility, cost_micros: cost, partial: !jobs.every(finished) } });\n}\nreturn out;"
      }
    },
    {
      "id": "46b88b13-4854-40c8-81d9-198ddef2872f",
      "name": "Visibility Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1792,
        192
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $input.all().flatMap(i => i.json.visibility.filter(v => v.answered).map(v => ({ json: {\n  site_id: i.json.site.site_id, week: i.json.week, model: v.model, prompt: v.prompt, mentioned: v.mentioned,\n  position: v.position, competitors: v.competitors.join(', '), snippet: v.snippet.slice(0, 500)\n} })));"
      }
    },
    {
      "id": "b5887967-1442-4179-aa62-7b3f1c98a1ca",
      "name": "Save Visibility",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2016,
        192
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_ai_visibility"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        }
      }
    },
    {
      "id": "9ba69dea-433d-4421-bd32-553351ceae43",
      "name": "Load Visibility History",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1792,
        384
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_ai_visibility"
        },
        "returnAll": true
      },
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "id": "8cc5a0aa-b0a7-4ef9-b02c-d5358847c591",
      "name": "Render Weekly Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2016,
        384
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// The weekly email: AI visibility first, then the fixes worth making this week.\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: 'Weekly SEO and AI search report', vis_h: 'How AI assistants answer your customers\\' questions', model: 'Model', rate: 'Mentions you', last: 'Last week', pos: 'Average position when mentioned',\n    prompt: 'Question', comp_h: 'Competitors named instead', said_h: 'What the models say about you', ai_h: 'Visits from AI assistants (Google Analytics)', week: 'Week', visits: 'Visits',\n    opp_h: 'Pages to improve this week', why: 'Why', title_l: 'Title', desc_l: 'Description', now: 'Now', new: 'Suggested', answer: 'Opening paragraph to add', missing: 'Questions the page does not answer yet', faq: 'FAQ to add', schema: 'Schema', links: 'Link to it from',\n    gaps_h: 'New pages worth writing', cann_h: 'Pages competing for the same search', queries: 'Searches', none_vis: 'No AI prompts set for this site. Add a few to ai_prompts in seo_sites.',\n    vis_note: 'The models answer from what they learned in training, without searching the web, so this shows what they know about you, not what ChatGPT or Perplexity show when they search.',\n    ga_note: 'The visits from AI assistants below are the real-world number.',\n    partial: 'Some jobs had not finished after 30 hours; this report uses what was ready.',\n    footer: 'Opportunities come from Search Console data. Suggestions are written by an AI model and can be wrong: check facts and placeholders in [brackets] before you publish.', cost: 'AI cost of this report: USD {cost}',\n    subject: 'Weekly SEO report for {site}: {headline}'\n  },\n  es: {\n    title: 'Reporte semanal de SEO y búsqueda con IA', vis_h: 'Cómo responden los asistentes de IA las preguntas de tus clientes', model: 'Modelo', rate: 'Te menciona', last: 'Semana pasada', pos: 'Posición promedio cuando te menciona',\n    prompt: 'Pregunta', comp_h: 'Competidores que nombran en tu lugar', said_h: 'Lo que dicen los modelos de ti', ai_h: 'Visitas desde asistentes de IA (Google Analytics)', week: 'Semana', visits: 'Visitas',\n    opp_h: 'Páginas para mejorar esta semana', why: 'Por qué', title_l: 'Título', desc_l: 'Descripción', now: 'Ahora', new: 'Sugerido', answer: 'Párrafo inicial para agregar', missing: 'Preguntas que la página aún no responde', faq: 'FAQ para agregar', schema: 'Schema', links: 'Enlázala desde',\n    gaps_h: 'Páginas nuevas que vale la pena escribir', cann_h: 'Páginas que compiten por la misma búsqueda', queries: 'Búsquedas', none_vis: 'Este sitio no tiene preguntas de IA. Agrega algunas en ai_prompts de seo_sites.',\n    vis_note: 'Los modelos responden con lo que aprendieron en su entrenamiento, sin buscar en la web, así que esto muestra lo que saben de ti, no lo que muestran ChatGPT o Perplexity cuando buscan.',\n    ga_note: 'Las visitas desde asistentes de IA que ves abajo son la cifra real.',\n    partial: 'Algunas tareas no terminaron en 30 horas; este reporte usa lo que estaba listo.',\n    footer: 'Las oportunidades vienen de los datos de Search Console. Las sugerencias las escribe un modelo de IA y pueden equivocarse: revisa los datos y los espacios entre [corchetes] antes de publicar.', cost: 'Costo de IA de este reporte: USD {cost}',\n    subject: 'Reporte SEO semanal de {site}: {headline}'\n  }\n};\nconst td = 'padding:6px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top;';\nconst history = $input.all().map(i => i.json).filter(x => x.site_id);\nconst out = [];\nfor (const item of $('Gather Results').all()) {\n  const g = item.json;\n  const T = TEXT[g.site.language] || TEXT.en;\n  const f = g.facts || {};\n  const rv = g.review || {};\n  const domain = g.site.domain.replace(/^https?:[/][/]/, '').replace(/[/]$/, '');\n  const prevWeeks = history.filter(x => x.site_id === g.site.site_id && x.week < g.week).map(x => x.week).sort();\n  const lastWeek = prevWeeks[prevWeeks.length - 1];\n  const last = history.filter(x => x.site_id === g.site.site_id && x.week === lastWeek);\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(domain) + ' · ' + esc(g.week) + '</p>');\n  h.push('<h2 style=\"margin:4px 0 8px\">' + esc(rv.headline || T.title) + '</h2>');\n  if (rv.overview) h.push('<p>' + esc(rv.overview) + '</p>');\n  if (g.partial) h.push('<p style=\"color:#a16207;font-size:12px\">' + esc(T.partial) + '</p>');\n  // AI visibility\n  h.push('<h3>' + esc(T.vis_h) + '</h3>');\n  const vis = g.visibility.filter(v => v.answered);\n  if (!vis.length) h.push('<p>' + esc(T.none_vis) + '</p>');\n  else {\n    const models = vis.map(v => v.model).filter((x, k, a) => a.indexOf(x) === k);\n    h.push('<table style=\"border-collapse:collapse;width:100%\"><tr><th align=\"left\" style=\"' + td + '\">' + T.model + '</th><th align=\"right\" style=\"' + td + '\">' + T.rate + '</th><th align=\"right\" style=\"' + td + '\">' + T.last + '</th><th align=\"right\" style=\"' + td + '\">' + T.pos + '</th></tr>');\n    for (const m of models) {\n      const rows = vis.filter(v => v.model === m);\n      const hit = rows.filter(v => v.mentioned);\n      const lrows = last.filter(x => x.model === m);\n      const lhit = lrows.filter(x => x.mentioned === true || x.mentioned === 'true');\n      const avg = hit.length ? Math.round(hit.reduce((a, v) => a + v.position, 0) / hit.length * 10) / 10 : '-';\n      h.push('<tr><td style=\"' + td + '\">' + esc(m) + '</td><td align=\"right\" style=\"' + td + '\">' + hit.length + ' / ' + rows.length + '</td><td align=\"right\" style=\"' + td + '\">' + (lrows.length ? lhit.length + ' / ' + lrows.length : '-') + '</td><td align=\"right\" style=\"' + td + '\">' + avg + '</td></tr>');\n    }\n    h.push('</table>');\n    const prompts = vis.map(v => v.prompt).filter((x, k, a) => a.indexOf(x) === k);\n    h.push('<table style=\"border-collapse:collapse;width:100%;margin-top:10px;font-size:13px\"><tr><th align=\"left\" style=\"' + td + '\">' + T.prompt + '</th>' + models.map(m => '<th style=\"' + td + '\">' + esc(m.split('@')[0]) + '</th>').join('') + '</tr>');\n    for (const p of prompts) h.push('<tr><td style=\"' + td + '\">' + esc(p) + '</td>' + models.map(m => { const v = vis.find(x => x.model === m && x.prompt === p); return '<td align=\"center\" style=\"' + td + '\">' + (!v ? '-' : v.mentioned ? '<span style=\"color:#15803d\">#' + v.position + '</span>' : '<span style=\"color:#b91c1c\">no</span>') + '</td>'; }).join('') + '</tr>');\n    h.push('</table>');\n    const comp = {};\n    for (const v of vis) for (const c of v.competitors) comp[c] = (comp[c] || 0) + 1;\n    const compList = Object.keys(comp).sort((a, b) => comp[b] - comp[a]);\n    if (compList.length) h.push('<p><b>' + esc(T.comp_h) + ':</b> ' + compList.map(c => esc(c) + ' (' + comp[c] + ')').join(', ') + '</p>');\n    const said = vis.filter(v => v.mentioned && v.snippet).slice(0, 4);\n    if (said.length) h.push('<h4 style=\"margin:12px 0 4px\">' + esc(T.said_h) + '</h4>' + said.map(v => '<p style=\"margin:0 0 8px;font-size:13px;color:#444;border-left:3px solid #e5e7eb;padding-left:8px\">\"...' + esc(v.snippet) + '...\" <span style=\"color:#666\">(' + esc(v.model) + ')</span></p>').join(''));\n    h.push('<p style=\"color:#666;font-size:12px\">' + esc(T.vis_note) + ((f.ai_weeks || []).length ? ' ' + esc(T.ga_note) : '') + '</p>');\n  }\n  if ((f.ai_weeks || []).length) {\n    h.push('<h3>' + esc(T.ai_h) + '</h3><table style=\"border-collapse:collapse\"><tr><th align=\"left\" style=\"' + td + '\">' + T.week + '</th><th align=\"right\" style=\"' + td + '\">' + T.visits + '</th></tr>' + f.ai_weeks.map(x => '<tr><td style=\"' + td + '\">' + esc(x.week) + '</td><td align=\"right\" style=\"' + td + '\">' + x.sessions + '</td></tr>').join('') + '</table>');\n    if ((f.ai_sources || []).length) h.push('<p style=\"color:#666;font-size:12px\">' + f.ai_sources.map(x => esc(x.source) + ' ' + x.sessions_8_weeks).join(', ') + '</p>');\n  }\n  // Opportunities\n  const current = {};\n  for (const p of f.page_titles || []) current[p.url] = p;\n  const opp = {};\n  for (const p of f.opportunities || []) opp[p.url] = p;\n  if ((rv.pages || []).length) {\n    h.push('<h3>' + esc(T.opp_h) + '</h3>');\n    for (const p of rv.pages) {\n      const c = current[p.url] || {};\n      const o = opp[p.url] || {};\n      h.push('<div style=\"border:1px solid #e5e7eb;border-radius:6px;padding:10px;margin:0 0 12px\">');\n      h.push('<p style=\"margin:0 0 6px\"><b><a href=\"' + esc(p.url) + '\">' + esc(p.url) + '</a></b></p>');\n      h.push('<p style=\"margin:0 0 6px\"><b>' + esc(T.why) + ':</b> ' + esc(p.why) + '</p>');\n      if ((o.queries || []).length) h.push('<p style=\"margin:0 0 6px;font-size:12px;color:#666\">' + esc(T.queries) + ': ' + o.queries.slice(0, 6).map(q => esc(q.query) + ' (' + q.impressions + ', #' + q.position + ')').join('; ') + '</p>');\n      h.push('<p style=\"margin:0 0 4px\"><b>' + esc(T.title_l) + '</b><br><span style=\"color:#666\">' + esc(T.now) + ':</span> ' + esc(c.title || '-') + '<br><span style=\"color:#15803d\">' + esc(T.new) + ':</span> ' + esc(p.new_title) + '</p>');\n      h.push('<p style=\"margin:0 0 4px\"><b>' + esc(T.desc_l) + '</b><br><span style=\"color:#666\">' + esc(T.now) + ':</span> ' + esc(c.description || '-') + '<br><span style=\"color:#15803d\">' + esc(T.new) + ':</span> ' + esc(p.new_description) + '</p>');\n      if (p.answer_first) h.push('<p style=\"margin:6px 0 4px\"><b>' + esc(T.answer) + ':</b><br>' + esc(p.answer_first) + '</p>');\n      if ((p.missing_questions || []).length) h.push('<p style=\"margin:6px 0 4px\"><b>' + esc(T.missing) + ':</b></p><ul style=\"margin:0\">' + p.missing_questions.map(q => '<li>' + esc(q) + '</li>').join('') + '</ul>');\n      if ((p.faq || []).length) h.push('<p style=\"margin:6px 0 4px\"><b>' + esc(T.faq) + ':</b></p><ul style=\"margin:0\">' + p.faq.map(q => '<li><b>' + esc(q.q) + '</b> ' + esc(q.a) + '</li>').join('') + '</ul>');\n      if (p.schema_suggestion) h.push('<p style=\"margin:6px 0 0;font-size:13px\"><b>' + esc(T.schema) + ':</b> ' + esc(p.schema_suggestion) + '</p>');\n      if ((p.internal_links_from || []).length) h.push('<p style=\"margin:4px 0 0;font-size:13px\"><b>' + esc(T.links) + ':</b> ' + p.internal_links_from.map(esc).join(', ') + '</p>');\n      h.push('</div>');\n    }\n  }\n  if ((rv.gaps || []).length) h.push('<h3>' + esc(T.gaps_h) + '</h3>' + rv.gaps.map(x => '<div style=\"margin:0 0 10px\"><b>' + esc(x.topic) + '</b><br><span style=\"font-size:12px;color:#666\">' + esc(T.queries) + ': ' + x.queries.map(esc).join(', ') + '</span><br>' + esc(x.brief) + '</div>').join(''));\n  if ((rv.cannibalization || []).length) h.push('<h3>' + esc(T.cann_h) + '</h3><ul>' + rv.cannibalization.map(x => '<li><b>' + esc(x.query) + ':</b> ' + esc(x.recommendation) + '</li>').join('') + '</ul>');\n  h.push('<p style=\"color:#666;font-size:12px;margin-top:20px\">' + esc(T.footer) + (g.cost_micros ? '<br>' + esc(fill(T.cost, { cost: (g.cost_micros / 1000000).toFixed(4) })) : '') + '</p></div>');\n  if (!g.site.report_email) continue;\n  out.push({ json: { row_id: g.row_id, to: g.site.report_email, subject: fill(T.subject, { site: domain, headline: rv.headline || g.week }), html: h.join('') } });\n}\nreturn out;"
      }
    },
    {
      "id": "c3577221-731c-4a99-a453-72cd48d579fc",
      "name": "Email Weekly Report",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        2240,
        384
      ],
      "parameters": {
        "resource": "email",
        "operation": "send",
        "fromEmail": "={{ $(\"Weekly Settings\").first().json.email_from }}",
        "toEmail": "={{ $json.to }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "html",
        "html": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "webhookId": "66203708-57f8-4557-86ba-6dab975e850d"
    },
    {
      "id": "1e98bd96-87fb-497d-8945-31e9d7dfcfcf",
      "name": "Mark Sent",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2464,
        384
      ],
      "parameters": {
        "resource": "row",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "seo_weekly"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "id",
              "condition": "eq",
              "keyValue": "={{ $(\"Render Weekly Report\").item.json.row_id }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "status": "sent",
            "sent_at": "={{ $now.toISO() }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "sent_at",
              "displayName": "sent_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ]
        }
      }
    },
    {
      "id": "c2095c1c-32ee-4aa2-903e-2c8cf57c0a35",
      "name": "Sticky Note: Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -480
      ],
      "parameters": {
        "content": "## Weekly SEO opportunities and AI visibility with Zabio\nOn Mondays, for every site in seo_sites: Search Console finds pages just below the top results, titles with low click-through, searches with no good page and pages competing for the same search. Zabio writes new titles, descriptions, an answer-first paragraph, FAQ and internal links for each. It also asks several vendors' models your ai_prompts and records whether they name you or your competitors.\n\nEvery job runs as a Zabio background request with a 24h deadline (batch queue, about half the list price). Every 2 hours the workflow collects finished jobs and emails the report.\n\nSetup guide: https://remarkablecloud.com/docs/automation/seo-geo-weekly-report/",
        "color": 4,
        "height": 440,
        "width": 700
      }
    },
    {
      "id": "267a83e2-5411-4ab3-899a-4568f803a407",
      "name": "Sticky Note: Visibility",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2200,
        -340
      ],
      "parameters": {
        "content": "### AI visibility\nvisibility_models lists the Zabio model ids to ask. Without web search the models answer from training, so this measures what they know about you. brand_names (comma separated) and competitors (JSON list) in seo_sites decide what counts as a mention. History goes to seo_ai_visibility for week-over-week comparison.",
        "color": 5,
        "height": 300,
        "width": 820
      }
    },
    {
      "id": "ae7dcf80-f8e7-4209-a82f-6f0b7bc2fe00",
      "name": "Sticky Note: Collect",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        640,
        500
      ],
      "parameters": {
        "content": "### Collecting results\nJobs are checked every 2 hours. A week is reported when every job has finished, or after 30 hours with what is ready. The row in seo_weekly moves from pending to sent.",
        "color": 6,
        "height": 200,
        "width": 1000
      }
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}