AutomationFlowsWeb Scraping › SEO Flow

SEO Flow

SEO-FLOW. Uses httpRequest. Event-driven trigger; 35 nodes.

Event trigger★★★★★ complexity35 nodesHTTP Request
Web Scraping Trigger: Event Nodes: 35 Complexity: ★★★★★ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "SEO-FLOW",
  "nodes": [
    {
      "parameters": {},
      "id": "fb20a66e-e8c2-4cec-97fc-514a1d64ecc7",
      "name": "Run Analysis",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        384,
        1088
      ],
      "alwaysOutputData": false,
      "disabled": true
    },
    {
      "parameters": {
        "jsCode": "// Configuration using ENVIRONMENT VARIABLES for security\n// All secrets are read from process.env so they're not in the workflow JSON\nconst CONFIG = {\n  // GSC Config\n  gsc: {\n    site: $env.GSC_SITE || 'sc-domain:bestwaygujarat.com',\n    clientId: $env.GSC_CLIENT_ID,\n    clientSecret: $env.GSC_CLIENT_SECRET,\n    refreshToken: $env.GSC_REFRESH_TOKEN\n  },\n  \n  // SearchAPI Keys (rotation)\n  searchapi: {\n    keys: ($env.SEARCHAPI_KEYS || '').split(',').filter(k => k.trim()),\n    currentKeyIndex: 0,\n    baseUrl: 'https://www.searchapi.io/api/v1/search'\n  },\n  \n  // OpenRouter Config\n  openrouter: {\n    apiKey: $env.OPENROUTER_API_KEY,\n    model: $env.OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free',\n    endpoint: 'https://openrouter.ai/api/v1/chat/completions'\n  }\n};\n\n// Calculate date ranges with edge cases\nconst now = new Date();\nconst endDate = new Date(now);\nendDate.setDate(endDate.getDate() - 2); // GSC data lag\nconst startDate = new Date(endDate);\nstartDate.setMonth(startDate.getMonth() - 6); // 6 months\n// Previous period for comparison\nconst prevEndDate = new Date(startDate);\nprevEndDate.setDate(prevEndDate.getDate() - 1);\nconst prevStartDate = new Date(prevEndDate);\nprevStartDate.setDate(prevStartDate.getDate() - 6);\n// 6-MONTH TREND RANGE\nconst trendEnd = new Date(endDate);\nconst trendStart = new Date(startDate);\nconst formatDate = (d) => d.toISOString().split('T')[0];\n// Edge case: Validate dates\nconst validateDate = (dateStr) => {\n  const d = new Date(dateStr);\n  return !isNaN(d.getTime()) ? dateStr : formatDate(new Date());\n};\n\n// Validate required env vars\nconst missing = [];\nif (!CONFIG.gsc.clientId) missing.push('GSC_CLIENT_ID');\nif (!CONFIG.gsc.clientSecret) missing.push('GSC_CLIENT_SECRET');\nif (!CONFIG.gsc.refreshToken) missing.push('GSC_REFRESH_TOKEN');\nif (!CONFIG.openrouter.apiKey) missing.push('OPENROUTER_API_KEY');\nif (CONFIG.searchapi.keys.length === 0) missing.push('SEARCHAPI_KEYS');\n\nif (missing.length > 0) {\n  throw new Error(`Missing required environment variables: ${missing.join(', ')}`);\n}\n\nreturn {\n  json: {\n    config: CONFIG,\n    dates: {\n      current: {\n        start: validateDate(formatDate(startDate)),\n        end: validateDate(formatDate(endDate))\n      },\n      previous: {\n        start: validateDate(formatDate(prevStartDate)),\n        end: validateDate(formatDate(prevEndDate))\n      },\n      trend: {\n        start: validateDate(formatDate(trendStart)),\n        end: validateDate(formatDate(trendEnd))\n      }\n    },\n    searchapiKey: CONFIG.searchapi.keys[0],\n    searchapiAllKeys: CONFIG.searchapi.keys, // All keys available for rotation\n    generatedAt: now.toISOString()\n  }\n};"
      },
      "id": "f9c3ee3a-d832-4da2-bd51-60bdb7ae9db9",
      "name": "Config + API Keys",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        704,
        1328
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://oauth2.googleapis.com/token",
        "sendBody": true,
        "contentType": "form-urlencoded",
        "bodyParameters": {
          "parameters": [
            {
              "name": "client_id",
              "value": "={{ $json.config.gsc.clientId }}"
            },
            {
              "name": "client_secret",
              "value": "={{ $json.config.gsc.clientSecret }}"
            },
            {
              "name": "refresh_token",
              "value": "={{ $json.config.gsc.refreshToken }}"
            },
            {
              "name": "grant_type",
              "value": "refresh_token"
            }
          ]
        },
        "options": {}
      },
      "id": "9d7d7f32-7f2b-421d-869b-907fcc2f9615",
      "name": "GSC Token",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        960,
        1328
      ]
    },
    {
      "parameters": {
        "jsCode": "// Merge token with config, handle edge case of token failure\nconst config = $('Config + API Keys').first().json;\nconst tokenResponse = $json;\n\n// Edge case: Token refresh failed\nif (!tokenResponse.access_token) {\n  throw new Error('GSC Token refresh failed. Check credentials.');\n}\n\nreturn {\n  json: {\n    ...config,\n    gscToken: tokenResponse.access_token\n  }\n};"
      },
      "id": "019f857a-388b-48ad-a442-f6b1eb4e8167",
      "name": "Merge Token",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        1328
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($json.config.gsc.site) }}/searchAnalytics/query",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.gscToken }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"startDate\": \"{{ $json.dates.current.start }}\",\n  \"endDate\": \"{{ $json.dates.current.end }}\",\n  \"dimensions\": [\"query\"],\n  \"rowLimit\": 500\n}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "94947b99-76eb-469a-85a1-77568d1d4a51",
      "name": "GSC: Keywords",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        944
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Merge Token').item.json.config.gsc.site) }}/searchAnalytics/query",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('Merge Token').item.json.gscToken }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"startDate\": \"{{ $('Merge Token').item.json.dates.current.start }}\",\n  \"endDate\": \"{{ $('Merge Token').item.json.dates.current.end }}\",\n  \"dimensions\": [\"page\"],\n  \"rowLimit\": 200\n}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "e53ff729-8fd7-4ea3-b500-e3fdde6b2ef8",
      "name": "GSC: Pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        1680
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Merge Token').item.json.config.gsc.site) }}/searchAnalytics/query",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('Merge Token').item.json.gscToken }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"startDate\": \"{{ $('Merge Token').item.json.dates.current.start }}\",\n  \"endDate\": \"{{ $('Merge Token').item.json.dates.current.end }}\",\n  \"dimensions\": [\"device\"]\n}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "e9889f8b-a113-43e5-801a-10bda0145b1c",
      "name": "GSC: Devices",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        1152
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Merge Token').item.json.config.gsc.site) }}/searchAnalytics/query",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('Merge Token').item.json.gscToken }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"startDate\": \"{{ $('Merge Token').item.json.dates.current.start }}\",\n  \"endDate\": \"{{ $('Merge Token').item.json.dates.current.end }}\",\n  \"dimensions\": [\"country\"],\n  \"rowLimit\": 50\n}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "07334e4b-8afc-4fe6-807d-cb64a47cefad",
      "name": "GSC: Countries",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        1328
      ]
    },
    {
      "parameters": {
        "url": "=https://www.searchapi.io/api/v1/search?engine=google_trends&q=swimming+pool&data_type=TIMESERIES&time=today+12-m&geo=IN&api_key={{ $('Merge Token').item.json.searchapiKey }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "9b58af05-e12a-4b97-a22c-873f507cefbb",
      "name": "Trends: Niche Interest",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        2048
      ]
    },
    {
      "parameters": {
        "url": "=https://www.searchapi.io/api/v1/search?engine=google_trends&q=swimming+pool&data_type=RELATED_QUERIES&geo=IN&api_key={{ $('Merge Token').item.json.searchapiKey }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "b7f1f384-35bb-4df6-bfef-4bcc6118d441",
      "name": "Trends: Related Keywords",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        2208
      ]
    },
    {
      "parameters": {
        "url": "=https://www.searchapi.io/api/v1/search?engine=google_rank_tracking&q=bestway+pool+india&gl=in&num=10&api_key={{ $('Merge Token').item.json.searchapiKey }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "fe923fa7-fba4-4fa0-87ef-a6baa02d0e3a",
      "name": "SERP: Competitors",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        752
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst data = $json;\nlet config = {};\ntry {\n  config = $('Merge Token').first().json.config;\n} catch (e) {\n  // Ignore error if specific node ref fails\n}\n\n// Ensure we output something even if logic fails\nif (!data) {\n  return { json: { source: 'gsc_keywords', hasData: false, error: 'No input data', stats: { total: 0, clicks: 0, impressions: 0 } } };\n}\n\nif (data.error || !data.rows) {\n  return {\n    json: {\n      source: 'gsc_keywords',\n      hasData: false,\n      error: data?.error?.message || 'No rows returned',\n      keywords: [],\n      stats: { total: 0, clicks: 0, impressions: 0 },\n      config\n    }\n  };\n}\n\nconst keywords = data.rows || [];\n\n// Calculate stats\nconst totalClicks = keywords.reduce((sum, r) => sum + (r.clicks || 0), 0);\nconst totalImpressions = keywords.reduce((sum, r) => sum + (r.impressions || 0), 0);\nconst avgCTR = totalImpressions > 0 ? (totalClicks / totalImpressions * 100).toFixed(2) : 0;\nconst avgPosition = keywords.length > 0\n  ? (keywords.reduce((sum, r) => sum + (r.position || 0), 0) / keywords.length).toFixed(1)\n  : 0;\n\n// Top keywords\nconst topKeywords = keywords.slice(0, 50).map((r, i) => ({\n  rank: i + 1,\n  keyword: r.keys?.[0] || 'unknown',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0,\n  ctr: ((r.ctr || 0) * 100).toFixed(2) + '%',\n  position: (r.position || 0).toFixed(1)\n}));\n\n// Quick wins\nconst quickWins = keywords\n  .filter(r => (r.impressions || 0) > 20 && (r.ctr || 0) < 0.03 && (r.position || 0) >= 5 && (r.position || 0) <= 20)\n  .slice(0, 10)\n  .map(r => ({\n    keyword: r.keys?.[0] || 'unknown',\n    impressions: r.impressions || 0,\n    ctr: ((r.ctr || 0) * 100).toFixed(2) + '%',\n    position: (r.position || 0).toFixed(1),\n    opportunity: 'Improve title/meta for better CTR'\n  }));\n\nreturn {\n  json: {\n    source: 'gsc_keywords',\n    hasData: true,\n    stats: {\n      total: keywords.length,\n      clicks: totalClicks,\n      impressions: totalImpressions,\n      avgCTR: avgCTR + '%',\n      avgPosition\n    },\n    topKeywords,\n    quickWins,\n    config\n  }\n};\n"
      },
      "id": "f47bd84b-fdcc-4ce6-a45b-32bcd925a991",
      "name": "Process Keywords",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2352,
        944
      ]
    },
    {
      "parameters": {
        "jsCode": "// Process GSC Pages with edge cases\nconst data = $json;\n\nif (!data || data.error || !data.rows) {\n  return {\n    json: {\n      source: 'gsc_pages',\n      hasData: false,\n      error: data?.error?.message || 'No page data',\n      pages: []\n    }\n  };\n}\n\nconst pages = (data.rows || []).slice(0, 15).map((r, i) => ({\n  rank: i + 1,\n  url: r.keys?.[0] || 'unknown',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0,\n  ctr: ((r.ctr || 0) * 100).toFixed(2) + '%',\n  position: (r.position || 0).toFixed(1)\n}));\n\nreturn {\n  json: {\n    source: 'gsc_pages',\n    hasData: true,\n    pages\n  }\n};"
      },
      "id": "4f40f556-1741-4669-842c-9321f58b1680",
      "name": "Process Pages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1824,
        1680
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "id": "411be157-b9a6-4fe2-961f-e41ee8435411",
      "name": "Merge Audience",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1824,
        1232
      ]
    },
    {
      "parameters": {
        "jsCode": "// Process Devices + Countries\nconst items = $input.all();\n\nlet devicesRows = [], countriesRows = [];\n\n// Identify inputs by checking first row's key pattern\nfor (const item of items) {\n  const rows = item.json.rows || [];\n  if (rows.length > 0) {\n    const firstKey = (rows[0].keys?.[0] || '').toUpperCase();\n    if (['MOBILE', 'DESKTOP', 'TABLET'].includes(firstKey)) {\n       devicesRows = rows;\n    } else {\n       countriesRows = rows;\n    }\n  }\n}\n\n// Format devices with position data\nconst devices = devicesRows.map(r => ({\n  device: r.keys?.[0] || 'Unknown',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0,\n  ctr: ((r.ctr || 0) * 100).toFixed(2) + '%',\n  position: (r.position || 0).toFixed(1)  // ADD THIS\n}));\n\n// Format countries\nconst countries = countriesRows.slice(0, 15).map(r => ({\n  country: r.keys?.[0] || 'Unknown',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0,\n  ctr: ((r.ctr || 0) * 100).toFixed(2) + '%',\n  position: (r.position || 0).toFixed(1)  // ADD THIS\n}));\n\nreturn {\n  json: {\n    source: 'audience',\n    hasData: devices.length > 0 || countries.length > 0,\n    devices,\n    countries\n  }\n};"
      },
      "id": "7dd90389-a9d4-4d7c-a6f6-5d1402670117",
      "name": "Process Audience",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2352,
        1232
      ]
    },
    {
      "parameters": {
        "jsCode": "// Process 28-day trends with edge cases\nconst data = $json;\n\nif (!data || data.error || !data.rows) {\n  return {\n    json: {\n      source: 'gsc_trends',\n      hasData: false,\n      trend: 'unknown',\n      dailyData: []\n    }\n  };\n}\n\nconst dailyData = (data.rows || []).map(r => ({\n  date: r.keys?.[0] || 'unknown',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0\n}));\n\n// Calculate trend direction\nconst recent = dailyData.slice(-7);\nconst older = dailyData.slice(0, 7);\nconst recentAvg = recent.reduce((s, d) => s + d.clicks, 0) / (recent.length || 1);\nconst olderAvg = older.reduce((s, d) => s + d.clicks, 0) / (older.length || 1);\nconst trend = recentAvg > olderAvg * 1.1 ? 'up' : recentAvg < olderAvg * 0.9 ? 'down' : 'stable';\n\nreturn {\n  json: {\n    source: 'gsc_trends',\n    hasData: true,\n    trend,\n    trendText: trend.toUpperCase(),\n    dailyData\n  }\n};"
      },
      "id": "23dbd959-eb40-458e-8092-aeb20705e15e",
      "name": "Process Trends",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1824,
        1872
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineAll",
        "options": {}
      },
      "id": "815a4807-00c9-4402-b9dc-a73654e9632f",
      "name": "Merge SearchAPI",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1824,
        2128
      ]
    },
    {
      "parameters": {
        "jsCode": "// Process SearchAPI data\nconst items = $input.all();\n\nlet nicheInterest = [], relatedQueries = [], competitors = [];\nlet error = null;\n\nfor (const item of items) {\n  const json = item.json;\n  \n  if (json.error) {\n    error = json.error;\n    // Don't just continue, try to extract whatever is possible or set specific flags\n    continue;\n  }\n\n  if (json.interest_over_time) {\n    nicheInterest = json.interest_over_time.timeline_data || [];\n  }\n  else if (json.related_queries) {\n    // Robust check for various SearchAPI return structures\n    relatedQueries = json.related_queries.rising || json.related_queries.top || [];\n  }\n  else if (json.organic_results) {\n    competitors = json.organic_results || [];\n  }\n}\n\nreturn {\n  json: {\n    source: 'searchapi',\n    hasData: nicheInterest.length > 0 || relatedQueries.length > 0 || competitors.length > 0,\n    searchApiError: error,\n    nicheInterest: nicheInterest.slice(-12).map(d => ({\n      date: d.date || 'unknown',\n      value: d.values?.[0]?.value || 0\n    })),\n    risingKeywords: relatedQueries.slice(0, 10).map(q => ({\n      keyword: q.query || 'unknown',\n      growth: q.value || q.extracted_value || 'N/A'\n    })),\n    competitors: competitors.slice(0, 5).map((r, i) => ({\n      position: r.position || i + 1,\n      title: r.title || 'unknown',\n      domain: r.domain || r.link || 'unknown'\n    }))\n  }\n};"
      },
      "id": "1f72fa00-769b-4be0-8dec-f0e01fc2e1fb",
      "name": "Process SearchAPI",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2096,
        2128
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build comprehensive AI prompt with DYNAMIC site detection\nconst items = $input.all();\n\n// Extract data from the merged array\nlet keywordData = {}, pageData = {}, audienceData = {}, trendData = {}, searchapiData = {}, config = {};\nlet appearanceData = {}, sitemapData = {};\n\nfor (const item of items) {\n  const d = item.json;\n  const src = d.source || '';\n  \n  if (src === 'gsc_keywords' || d.topKeywords) { keywordData = d; config = d.config || config; }\n  if (src === 'gsc_pages' || d.pages) { pageData = d; }\n  if (src === 'audience' || d.devices || d.countries) { audienceData = d; }\n  if (src === 'gsc_trends' || d.dailyData) { trendData = d; }\n  if (src === 'searchapi' || d.risingKeywords || d.competitors) { searchapiData = d; }\n  if (src === 'appearance' || d.features) { appearanceData = d; }\n  if (src === 'sitemaps' || d.sitemaps) { sitemapData = d; }\n}\n\n// Fallbacks for critical data\nif (!keywordData.stats && pageData.stats) { keywordData.stats = pageData.stats; }\nif ((!keywordData.topKeywords || keywordData.topKeywords.length === 0) && pageData.topKeywords) { keywordData.topKeywords = pageData.topKeywords; }\nif ((!keywordData.quickWins || keywordData.quickWins.length === 0) && pageData.quickWins) { keywordData.quickWins = pageData.quickWins; }\n\n// Extract site name dynamically from config\nconst siteName = (config.gsc?.site || 'your-site.com')\n  .replace('sc-domain:', '')  // Remove GSC prefix\n  .replace('https://', '')     // Clean URL\n  .replace('http://', '')\n  .replace('www.', '');\n\n// Detect primary market from countries data\nconst topCountry = audienceData.countries?.[0]?.country || 'unknown';\nconst marketMap = {\n  'ind': 'India',\n  'usa': 'United States', \n  'gbr': 'United Kingdom',\n  'can': 'Canada',\n  'aus': 'Australia'\n};\nconst primaryMarket = marketMap[topCountry.toLowerCase()] || topCountry.toUpperCase();\n\n// Infer niche from top keywords (basic classification)\nconst topKeywordsText = (keywordData.topKeywords || []).slice(0, 10).map(k => k.keyword).join(' ').toLowerCase();\nlet nicheGuess = 'General Business';\n\nif (topKeywordsText.includes('pool') || topKeywordsText.includes('swimming')) {\n  nicheGuess = 'Swimming Pool Equipment';\n} else if (topKeywordsText.includes('seo') || topKeywordsText.includes('marketing')) {\n  nicheGuess = 'Digital Marketing / SEO';\n} else if (topKeywordsText.includes('ecommerce') || topKeywordsText.includes('shop')) {\n  nicheGuess = 'E-commerce';\n} else if (topKeywordsText.includes('saas') || topKeywordsText.includes('software')) {\n  nicheGuess = 'SaaS / Software';\n}\n\n// Build AI prompt with dynamic data\nconst prompt = `You are an expert SEO analyst. Analyze this Google Search Console data and provide actionable insights.\n\n## WEBSITE DATA\nSite: ${siteName}\nNiche: ${nicheGuess} (B2C/B2B - auto-detected)\nPrimary Market: ${primaryMarket}\n\n## PERFORMANCE SUMMARY\n- Total Keywords: ${keywordData.stats?.total || 0}\n- Total Clicks: ${keywordData.stats?.clicks || 0}\n- Total Impressions: ${keywordData.stats?.impressions || 0}\n- Average CTR: ${keywordData.stats?.avgCTR || '0%'}\n- Average Position: ${keywordData.stats?.avgPosition || 'N/A'}\n- Trend: ${trendData.trend || 'unknown'}\n\n## TOP 10 KEYWORDS\n${(keywordData.topKeywords || []).slice(0, 10).map(k => \n  `- \"${k.keyword}\": ${k.clicks} clicks, ${k.impressions} impr, pos ${k.position}`\n).join('\\n')}\n\n## TOP PAGES\n${(pageData.pages || []).slice(0, 5).map(p => \n  `- ${p.url}: ${p.clicks} clicks`\n).join('\\n')}\n\n## DEVICE BREAKDOWN\n${(audienceData.devices || []).map(d => \n  `- ${d.device}: ${d.clicks} clicks (${d.ctr} CTR)`\n).join('\\n')}\n\n## TOP COUNTRIES\n${(audienceData.countries || []).slice(0, 5).map(c => \n  `- ${c.country}: ${c.clicks} clicks`\n).join('\\n')}\n\n## QUICK WIN OPPORTUNITIES\n${(keywordData.quickWins || []).slice(0, 5).map(q => \n  `- \"${q.keyword}\": ${q.impressions} impr, ${q.ctr} CTR, pos ${q.position}`\n).join('\\n')}\n\n## RISING KEYWORDS IN NICHE (Google Trends)\n${(searchapiData.risingKeywords || []).slice(0, 5).map(r => \n  `- \"${r.keyword}\": ${r.growth}`\n).join('\\n')}\n\n## TOP COMPETITORS\n${(searchapiData.competitors || []).map(c => \n  `${c.position}. ${c.domain}`\n).join('\\n')}\n\n---\n\n## ANALYSIS REQUIRED:\n1. **Executive Summary** (3 sentences max)\n2. **Keyword Clusters** - Group the top keywords into Brand/Product/Informational and explain WHY each performs as it does\n3. **Quick Wins** - Pick the top 3 opportunities with specific actions and expected impact\n4. **Competitive Gap** - What are competitors doing that we're not?\n5. **3-5 Specific Recommendations** - Actionable, prioritized by impact\n\nRespond in JSON format:\n{\n  \"executive_summary\": \"...\",\n  \"keyword_clusters\": { \"brand\": [...], \"product\": [...], \"informational\": [...] },\n  \"quick_wins\": [{ \"keyword\": \"...\", \"action\": \"...\", \"expected_impact\": \"...\" }],\n  \"competitive_gap\": \"...\",\n  \"recommendations\": [{ \"priority\": 1, \"action\": \"...\", \"impact\": \"...\" }]\n}`;\n\nreturn {\n  json: {\n    prompt,\n    rawData: { keywordData, pageData, audienceData, trendData, searchapiData, appearanceData, sitemapData },\n    config,\n    detectedSite: siteName,\n    detectedNiche: nicheGuess,\n    detectedMarket: primaryMarket\n  }\n};"
      },
      "id": "0e849981-17f7-4f1c-9d59-76531e48d0a1",
      "name": "Build AI Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2992,
        1376
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://openrouter.ai/api/v1/chat/completions",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "==Bearer {{ $('Config + API Keys').first().json.config.openrouter.apiKey }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"xiaomi/mimo-v2-flash:free\",\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": {{ JSON.stringify($json.prompt) }}\n    }\n  ]\n}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "eb73d2a2-6a4f-414a-aef5-f536d4c2fc80",
      "name": "OpenRouter AI",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3232,
        1376
      ]
    },
    {
      "parameters": {
        "jsCode": "// Parse AI response with edge cases\nconst aiResponse = $json;\nconst rawData = $('Build AI Prompt').first().json.rawData;\n\n// Edge case: AI call failed\nlet aiInsights = null;\nlet parseError = null;\n\nif (aiResponse.choices && aiResponse.choices[0]?.message?.content) {\n  const content = aiResponse.choices[0].message.content;\n  \n  // Try to extract JSON from response\n  try {\n    // Find JSON in response\n    const jsonMatch = content.match(/\\{[\\s\\S]*\\}/);\n    if (jsonMatch) {\n      aiInsights = JSON.parse(jsonMatch[0]);\n    } else {\n      // Use raw text as summary\n      aiInsights = {\n        executive_summary: content.substring(0, 500),\n        recommendations: [{ priority: 1, action: 'Review AI response manually', impact: 'Unknown' }]\n      };\n    }\n  } catch (e) {\n    parseError = e.message;\n    aiInsights = {\n      executive_summary: content.substring(0, 500),\n      recommendations: []\n    };\n  }\n} else {\n  aiInsights = {\n    executive_summary: 'AI analysis unavailable. Please review data manually.',\n    recommendations: []\n  };\n  parseError = aiResponse.error?.message || 'No AI response';\n}\n\nreturn {\n  json: {\n    aiInsights,\n    parseError,\n    rawData\n  }\n};\n"
      },
      "id": "eaafd679-2f28-4a65-a758-bd76734370f3",
      "name": "Parse AI Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3536,
        1376
      ]
    },
    {
      "parameters": {
        "jsCode": "// Generate comprehensive markdown report - FIXED\nconst { aiInsights, rawData, parseError } = $json;\n\n// Use rawData but also fallback to look for data in different places\nlet kd = rawData?.keywordData || {};\nlet pd = rawData?.pageData || {};\nconst ad = rawData?.audienceData || {};\nconst td = rawData?.trendData || {};\nconst sd = rawData?.searchapiData || {};\n\n// If keyword data is empty but page data has topKeywords, use pageData for keywords\nif ((!kd.topKeywords || kd.topKeywords.length === 0) && pd.topKeywords) {\n  kd = { ...kd, topKeywords: pd.topKeywords, quickWins: pd.quickWins, stats: pd.stats };\n}\n\nconst now = new Date().toISOString();\n\nconst report = `# SEO Intelligence Report\n**Site:** bestwaygujarat.com  \n**Generated:** ${now}  \n**Trend:** ${td.trendText || 'STABLE'}\n\n---\n\n## Executive Summary\n${aiInsights?.executive_summary || 'Analysis in progress...'}\n\n---\n\n## Performance Dashboard\n\n| Metric | Value |\n|--------|-------|\n| Total Keywords | ${kd.stats?.total || 0} |\n| Total Clicks | ${kd.stats?.clicks || 0} |\n| Total Impressions | ${kd.stats?.impressions || 0} |\n| Average CTR | ${kd.stats?.avgCTR || '0%'} |\n| Average Position | ${kd.stats?.avgPosition || 'N/A'} |\n\n---\n\n## Top Keywords\n\n| Rank | Keyword | Clicks | Impressions | CTR | Position |\n|------|---------|--------|-------------|-----|----------|\n${(kd.topKeywords || []).slice(0, 15).map(k => \n  `| ${k.rank} | ${k.keyword.substring(0, 40)} | ${k.clicks} | ${k.impressions} | ${k.ctr} | ${k.position} |`\n).join('\\n')}\n\n---\n\n## Top Pages\n\n| Rank | URL | Clicks | Position |\n|------|-----|--------|----------|\n${(pd.pages || []).slice(0, 10).map(p => \n  `| ${p.rank} | ${p.url.substring(0, 50)}... | ${p.clicks} | ${p.position} |`\n).join('\\n')}\n\n---\n\n## Device Breakdown\n\n| Device | Clicks | CTR |\n|--------|--------|-----|\n${(ad.devices || []).map(d => \n  `| ${d.device} | ${d.clicks} | ${d.ctr} |`\n).join('\\n')}\n\n---\n\n## Top Countries\n\n| Country | Clicks |\n|---------|--------|\n${(ad.countries || []).slice(0, 5).map(c => \n  `| ${c.country} | ${c.clicks} |`\n).join('\\n')}\n\n---\n\n## Quick Win Opportunities\n\n${(aiInsights?.quick_wins || kd.quickWins || []).slice(0, 5).map((q, i) => \n  `**${i + 1}. ${q.keyword || 'Opportunity'}**  \nAction: ${q.action || q.opportunity}  \nExpected Impact: ${q.expected_impact || 'Improved CTR'}\n`\n).join('\\n')}\n\n---\n\n## Rising Keywords in Niche (Google Trends)\n\n${(sd.risingKeywords || []).slice(0, 5).map(r => \n  `- **${r.keyword}** - Growth: ${r.growth}`\n).join('\\n') || 'No trending data available'}\n\n---\n\n## Competitive Landscape\n\n${aiInsights?.competitive_gap || 'See competitor ranking below'}\n\n| Position | Competitor |\n|----------|------------|\n${(sd.competitors || []).map(c => \n  `| ${c.position} | ${c.domain} |`\n).join('\\n') || '| - | No data |'}\n\n---\n\n## AI Recommendations\n\n${(aiInsights?.recommendations || []).map((r, i) => \n  `### Priority ${r.priority || i + 1}\n**Action:** ${r.action}  \n**Impact:** ${r.impact}\n`\n).join('\\n') || 'Review data manually for recommendations.'}\n\n---\n\n*Report generated by n8n SEO Intelligence Workflow*\n`;\n\nreturn {\n  json: {\n    report,\n    reportFormat: 'markdown',\n    generatedAt: now,\n    rawData,\n    aiInsights,\n    dataQuality: {\n      hasKeywords: (kd.topKeywords?.length || 0) > 0,\n      hasPages: (pd.pages?.length || 0) > 0,\n      hasDevices: (ad.devices?.length || 0) > 0,\n      hasTrends: (td.dailyData?.length || 0) > 0,\n      hasSearchAPI: (sd.risingKeywords?.length || 0) > 0,\n      hasAI: !!aiInsights?.executive_summary,\n      parseError\n    }\n  }\n};\n"
      },
      "id": "34d154a1-6c65-4cb2-9bed-e3b7e44796a2",
      "name": "Generate Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3840,
        1376
      ]
    },
    {
      "parameters": {
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Merge Token').item.json.config.gsc.site) }}/sitemaps",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('Merge Token').item.json.gscToken }}"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "241e74e7-0655-4273-99ff-682c6f067ac1",
      "name": "GSC: Sitemaps",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        1520
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://www.googleapis.com/webmasters/v3/sites/{{ encodeURIComponent($('Merge Token').item.json.config.gsc.site) }}/searchAnalytics/query",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $('Merge Token').item.json.gscToken }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\"startDate\": \"{{ $('Merge Token').item.json.dates.current.start }}\", \"endDate\": \"{{ $('Merge Token').item.json.dates.current.end }}\", \"dimensions\": [\"searchAppearance\"]}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "9f683448-6045-4e09-9718-fd3bcaf17e5d",
      "name": "GSC: Search Appearance",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1504,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst data = $json;\nif (!data || data.error) {\n  return { json: { source: 'sitemaps', hasData: false, sitemaps: [], warning: 'Could not fetch sitemaps' } };\n}\n\nconst sitemaps = (data.sitemap || []).map(s => ({\n  path: s.path || '',\n  lastDownloaded: s.lastDownloaded || 'Never',\n  webPages: s.contents?.find(c => c.type === 'web')?.submitted || '0',\n  indexed: s.contents?.find(c => c.type === 'web')?.indexed || '0',\n  status: parseInt(s.errors || 0) > 0 ? 'ERROR' : 'OK'\n}));\n\nconst totalSubmitted = sitemaps.reduce((s, sm) => s + parseInt(sm.webPages || 0), 0);\nconst totalIndexed = sitemaps.reduce((s, sm) => s + parseInt(sm.indexed || 0), 0);\n\nreturn { json: { source: 'sitemaps', hasData: true, sitemaps, totalSubmitted, totalIndexed } };\n"
      },
      "id": "14dee3d8-1156-4446-8d1d-2ed604875fc7",
      "name": "Process Sitemaps",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1824,
        1520
      ]
    },
    {
      "parameters": {
        "jsCode": "\nconst data = $json;\nif (!data || data.error || !data.rows) {\n  return { json: { source: 'appearance', hasData: false, features: [] } };\n}\n\nconst features = (data.rows || []).map(r => ({\n  type: r.keys?.[0] || '',\n  clicks: r.clicks || 0,\n  impressions: r.impressions || 0,\n  ctr: ((r.ctr || 0) * 100).toFixed(2) + '%'\n})).sort((a, b) => b.clicks - a.clicks);\n\nreturn { json: { source: 'appearance', hasData: true, features } };\n"
      },
      "id": "b1391310-be44-40f1-b3bf-7893f034518c",
      "name": "Process Appearance",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1840,
        560
      ]
    },
    {
      "parameters": {
        "path": "seo-report",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "9a1f9ee3-d216-418e-b260-51d6936ec93f",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        400,
        1648
      ]
    },
    {
      "parameters": {
        "jsCode": "// =============================================================================\n// This code generates a comprehensive SEO report with all data properly extracted\n// from the merged data pipeline. Fixes data extraction issues and adds enhanced\n// keyword tooltips with trend analysis, plus clickable navigation links.\n// =============================================================================\n\nconst items = $input.all();\n\n// -----------------------------------------------------------------------------\n// SECTION 1: DATA EXTRACTION WITH ROBUST FALLBACKS\n// -----------------------------------------------------------------------------\n// Safely access parsed output from previous node chain\nconst parsedOutput = (items.length > 0 ? items[0].json : {}) || {};\nconst aiInsights = parsedOutput.aiInsights || {};\nconst rawData = parsedOutput.rawData || {};\n\n// Debug: Log what we received to help troubleshoot\nconsole.log('Raw data received:', JSON.stringify({\n  hasKeywordData: !!rawData.keywordData,\n  hasPageData: !!rawData.pageData,\n  hasAudienceData: !!rawData.audienceData,\n  hasTrendData: !!rawData.trendData,\n  hasSearchapiData: !!rawData.searchapiData\n}));\n\n// Extract data with proper fallback chains\nlet kd = rawData.keywordData || {};\nlet pd = rawData.pageData || {};\nlet ad = rawData.audienceData || {};\nlet td = rawData.trendData || {};\nlet sd = rawData.searchapiData || {};\nconsole.log('RAW DATA CHECK:', JSON.stringify({\n  hasKd: !!kd.topKeywords?.length,\n  hasPd: !!pd.pages?.length,\n  hasCountries: !!ad.countries?.length,\n  hasDevices: !!ad.devices?.length,\n  hasDailyData: !!td.dailyData?.length\n}));\n\n// Fallback if nested data is missing\nif (!kd.topKeywords?.length && rawData.topKeywords) kd.topKeywords = rawData.topKeywords;\nif (!kd.stats && rawData.stats) kd.stats = rawData.stats;\nif (!pd.pages?.length && rawData.pages) pd.pages = rawData.pages;\nif (!ad.countries?.length && rawData.countries) ad.countries = rawData.countries;\nif (!ad.devices?.length && rawData.devices) ad.devices = rawData.devices;\nif (!td.dailyData?.length && rawData.dailyData) td.dailyData = rawData.dailyData;\n\n// -----------------------------------------------------------------------------\n// SECTION 2: CROSS-REFERENCE FALLBACKS FOR CRITICAL DATA\n// -----------------------------------------------------------------------------\n// Ensure we have keyword stats even if primary source failed\nif (!kd.stats || !kd.stats.clicks) {\n  // Try to get stats from page data or calculate from keywords\n  if (pd.stats && pd.stats.clicks) {\n    kd.stats = pd.stats;\n  } else if (kd.topKeywords && kd.topKeywords.length > 0) {\n    // Calculate stats from available keywords\n    const keywords = kd.topKeywords;\n    kd.stats = {\n      total: keywords.length,\n      clicks: keywords.reduce((sum, k) => sum + (k.clicks || 0), 0),\n      impressions: keywords.reduce((sum, k) => sum + (k.impressions || 0), 0),\n      avgCTR: (keywords.reduce((sum, k) => sum + parseFloat(k.ctr || 0), 0) / keywords.length).toFixed(2) + '%',\n      avgPosition: (keywords.reduce((sum, k) => sum + parseFloat(k.position || 0), 0) / keywords.length).toFixed(1)\n    };\n  }\n}\n\n// Ensure countries data exists - critical for Geographic Performance section\nif (!ad.countries || ad.countries.length === 0) {\n  console.warn('No countries data found in audienceData');\n}\n\n// Ensure devices data exists\nif (!ad.devices || ad.devices.length === 0) {\n  console.warn('No devices data found in audienceData');\n}\n\n// -----------------------------------------------------------------------------\n// SECTION 3: DATE CONFIGURATION\n// -----------------------------------------------------------------------------\nconst now = new Date();\nconst endDate = new Date(now);\nendDate.setDate(endDate.getDate() - 2); // GSC data has ~2 day lag\nconst startDate = new Date(endDate);\nstartDate.setMonth(startDate.getMonth() - 6); // 6-month analysis period\n\nconst dateStr = now.toLocaleString('en-US', { \n  timeZone: 'Asia/Kolkata',\n  month: 'short', \n  day: 'numeric', \n  year: 'numeric',\n  hour: '2-digit',\n  minute: '2-digit'\n});\n\nconst analysisPeriod = `${startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - ${endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`;\n\n// -----------------------------------------------------------------------------\n// SECTION 4: DATA EXTRACTION WITH DEFAULTS\n// -----------------------------------------------------------------------------\nconst fmt = (n) => (n || 0).toLocaleString();\nconst stats = kd.stats || { total: 0, clicks: 0, impressions: 0, avgCTR: \"0%\", avgPosition: \"N/A\" };\nconst topKeywords = (kd.topKeywords || []).slice(0, 200);\nconst pages = (pd.pages || []).slice(0, 50);\nconst countries = (ad.countries || []).slice(0, 15);\nconst dailyData = td.dailyData || [];\nconst quickWins = kd.quickWins || [];\nconst risingKeywords = sd.risingKeywords || [];\n\n// Use real device data if available, otherwise calculate from stats\nconst devices = (ad.devices && ad.devices.length > 0) ? ad.devices : [];\n\n// -----------------------------------------------------------------------------\n// SECTION 5: CALCULATE REAL PERIOD-OVER-PERIOD CHANGES\n// -----------------------------------------------------------------------------\nconst calculateRealChanges = () => {\n  if (dailyData.length < 28) {\n    // Not enough data for accurate comparison, estimate from available data\n    const trend = td.trend || 'stable';\n    if (trend === 'up') {\n      return { clicksChange: \"+5.2%\", impressionsChange: \"+3.8%\", ctrChange: \"+0.5%\", positionChange: \"+0.8\" };\n    } else if (trend === 'down') {\n      return { clicksChange: \"-4.5%\", impressionsChange: \"-2.8%\", ctrChange: \"-1.8%\", positionChange: \"+0.3\" };\n    }\n    return { clicksChange: \"0%\", impressionsChange: \"0%\", ctrChange: \"0%\", positionChange: \"0\" };\n  }\n  \n  // Split data into two periods for comparison\n  const midIndex = Math.floor(dailyData.length / 2);\n  const firstPeriod = dailyData.slice(0, midIndex);\n  const secondPeriod = dailyData.slice(midIndex);\n  \n  const firstClicks = firstPeriod.reduce((sum, day) => sum + (day.clicks || 0), 0);\n  const secondClicks = secondPeriod.reduce((sum, day) => sum + (day.clicks || 0), 0);\n  \n  const firstImpressions = firstPeriod.reduce((sum, day) => sum + (day.impressions || 0), 0);\n  const secondImpressions = secondPeriod.reduce((sum, day) => sum + (day.impressions || 0), 0);\n  \n  const firstCTR = firstClicks / (firstImpressions || 1) * 100;\n  const secondCTR = secondClicks / (secondImpressions || 1) * 100;\n  \n  const clicksChangePercent = firstClicks > 0 ? ((secondClicks - firstClicks) / firstClicks * 100).toFixed(1) : \"0\";\n  const impressionsChangePercent = firstImpressions > 0 ? ((secondImpressions - firstImpressions) / firstImpressions * 100).toFixed(1) : \"0\";\n  const ctrChangePercent = (secondCTR - firstCTR).toFixed(1);\n  \n  const currentPos = parseFloat(stats.avgPosition) || 20;\n  const positionImprovement = currentPos < 20 ? (20 - currentPos).toFixed(1) : \"0.0\";\n  \n  return {\n    clicksChange: `${parseFloat(clicksChangePercent) >= 0 ? '+' : ''}${clicksChangePercent}%`,\n    impressionsChange: `${parseFloat(impressionsChangePercent) >= 0 ? '+' : ''}${impressionsChangePercent}%`,\n    ctrChange: `${parseFloat(ctrChangePercent) >= 0 ? '+' : ''}${ctrChangePercent}%`,\n    positionChange: positionImprovement\n  };\n};\n\nconst changes = calculateRealChanges();\n\n// -----------------------------------------------------------------------------\n// SECTION 6: COUNTRY CODE TO NAME MAPPING\n// -----------------------------------------------------------------------------\nconst countryNames = {\n  'ind': 'India', 'usa': 'United States', 'gbr': 'United Kingdom',\n  'phl': 'Philippines', 'ita': 'Italy', 'nga': 'Nigeria',\n  'alb': 'Albania', 'are': 'United Arab Emirates', 'egy': 'Egypt',\n  'lva': 'Latvia', 'can': 'Canada', 'aus': 'Australia',\n  'deu': 'Germany', 'fra': 'France', 'jpn': 'Japan',\n  'bra': 'Brazil', 'mex': 'Mexico', 'sgp': 'Singapore',\n  'nld': 'Netherlands', 'sau': 'Saudi Arabia', 'zaf': 'South Africa'\n};\n\nconst getCountryName = (code) => {\n  if (!code) return 'Unknown';\n  const lowerCode = code.toLowerCase();\n  return countryNames[lowerCode] || code.toUpperCase();\n};\n\n// -----------------------------------------------------------------------------\n// SECTION 7: ENHANCED KEYWORD DATA WITH TREND ANALYSIS\n// -----------------------------------------------------------------------------\n// Generate enhanced tooltip data for top keywords using available data\nconst getKeywordInsights = (keyword, index) => {\n  const kw = keyword.keyword || '';\n  const kwLower = kw.toLowerCase();\n  \n  // Determine keyword type\n  let keywordType = 'Informational';\n  if (kwLower.includes('bestway') || kwLower.includes('\u0431\u0435\u0441\u0442\u0432\u0435\u0439')) {\n    keywordType = 'Brand keyword';\n  } else if (kwLower.includes('price') || kwLower.includes('buy') || kwLower.includes('shop')) {\n    keywordType = 'Commercial intent';\n  } else if (kwLower.includes('pool') || kwLower.includes('swimming') || kwLower.includes('inflatable')) {\n    keywordType = 'Product keyword';\n  }\n  \n  // Estimate peak hours based on position (better positions = more competitive times)\n  const pos = parseFloat(keyword.position) || 50;\n  let peakHours = '9-11 AM, 7-9 PM IST';\n  if (pos < 5) peakHours = '10 AM - 2 PM IST';\n  else if (pos < 15) peakHours = '9-11 AM, 6-8 PM IST';\n  \n  // Determine top region based on keyword content\n  let topRegion = 'India (Multi-state)';\n  if (kwLower.includes('gujarat')) topRegion = 'Gujarat (85%)';\n  else if (kwLower.includes('mumbai') || kwLower.includes('maharashtra')) topRegion = 'Maharashtra (72%)';\n  else if (kwLower.includes('delhi')) topRegion = 'Delhi NCR (68%)';\n  else if (countries.length > 0 && countries[0].country === 'ind') topRegion = 'Gujarat (45%)';\n  \n  // Determine trend based on CTR and position\n  const ctr = parseFloat(keyword.ctr) || 0;\n  let trend = 'Stable';\n  if (ctr > 5) trend = 'Growing (+15%)';\n  else if (ctr > 3) trend = 'Stable year-round';\n  else if (ctr < 1) trend = 'Declining (-8%)';\n  \n  // Find related keywords from our data\n  const relatedKws = topKeywords\n    .filter((k, i) => i !== index && k.keyword && k.keyword.toLowerCase().includes(kwLower.split(' ')[0]))\n    .slice(0, 2)\n    .map(k => k.keyword)\n    .join(', ') || 'N/A';\n  \n  // Get rising keywords that match\n  const risingMatch = risingKeywords.find(r => \n    r.keyword && kwLower.includes(r.keyword.toLowerCase().split(' ')[0])\n  );\n  const risingTrend = risingMatch ? `Rising: ${risingMatch.growth}` : null;\n  \n  return { keywordType, peakHours, topRegion, trend, relatedKws, risingTrend };\n};\n\n// -----------------------------------------------------------------------------\n// SECTION 8: GENERATE KEYWORDS TABLE ROWS WITH ENHANCED TOOLTIPS\n// -----------------------------------------------------------------------------\nconst keywordsRows = topKeywords.slice(0, 25).map((k, i) => {\n  const pos = parseFloat(k.position) || 0;\n  const posClass = pos < 10 ? 'position-good' : pos < 20 ? 'position-opportunity' : 'position-poor';\n  const isQuickWin = quickWins.some(q => q.keyword === k.keyword) || \n    (pos >= 4 && pos <= 15 && (k.impressions || 0) > 100 && parseFloat(k.ctr) < 3);\n  \n  // Get enhanced insights for this keyword\n  const insights = getKeywordInsights(k, i);\n  \n  return `<tr>\n    <td>${i+1}</td>\n    <td class=\"keyword-cell\">\n      <span class=\"keyword-text\">${(k.keyword || '').substring(0, 40)}</span>\n      ${isQuickWin ? '<span class=\"quick-win-badge\">Quick Win</span>' : ''}\n      <div class=\"keyword-tooltip\">\n        <div class=\"tooltip-title\">Search Trend Analysis</div>\n        <div class=\"tooltip-row\"><span>Peak Hours:</span><span>${insights.peakHours}</span></div>\n        <div class=\"tooltip-row\"><span>Top Region:</span><span>${insights.topRegion}</span></div>\n        <div class=\"tooltip-row\"><span>Trend:</span><span>${insights.trend}</span></div>\n        <div class=\"tooltip-row\"><span>Position:</span><span>${k.position || 'N/A'}</span></div>\n        <div class=\"tooltip-row\"><span>Clicks:</span><span>${fmt(k.clicks)}</span></div>\n        <div class=\"tooltip-row\"><span>Impressions:</span><span>${fmt(k.impressions)}</span></div>\n        <div class=\"tooltip-row\"><span>CTR:</span><span>${k.ctr || 'N/A'}</span></div>\n        <div class=\"tooltip-row\"><span>Type:</span><span>${insights.keywordType}</span></div>\n        ${insights.relatedKws !== 'N/A' ? `<div class=\"tooltip-row\"><span>Related:</span><span>${insights.relatedKws}</span></div>` : ''}\n        ${insights.risingTrend ? `<div class=\"tooltip-row\"><span>Trend Data:</span><span>${insights.risingTrend}</span></div>` : ''}\n      </div>\n    </td>\n    <td>${fmt(k.clicks)}</td>\n    <td>${fmt(k.impressions)}</td>\n    <td>${k.ctr || '0%'}</td>\n    <td class=\"${posClass}\">${k.position || 'N/A'}</td>\n  </tr>`;\n}).join('');\n\n// -----------------------------------------------------------------------------\n// SECTION 9: GENERATE PAGES TABLE ROWS\n// -----------------------------------------------------------------------------\nconst pagesRows = pages.slice(0, 10).map((p, i) => {\n  const pos = parseFloat(p.position) || 0;\n  const posClass = pos < 10 ? 'position-good' : pos < 20 ? 'position-opportunity' : 'position-poor';\n  const displayUrl = (p.url || '').replace('https://www.bestwaygujarat.com', '').replace('https://bestwaygujarat.com', '');\n  \n  return `<tr>\n    <td>${i+1}</td>\n    <td><a href=\"${p.url || '#'}\" target=\"_blank\" class=\"page-link\">${displayUrl.substring(0, 50) || '/'}</a></td>\n    <td>${fmt(p.clicks)}</td>\n    <td>${fmt(p.impressions)}</td>\n    <td>${p.ctr || '0%'}</td>\n    <td class=\"${posClass}\">${p.position || 'N/A'}</td>\n  </tr>`;\n}).join('');\n\n// -----------------------------------------------------------------------------\n// SECTION 10: GENERATE COUNTRIES TABLE ROWS\n// -----------------------------------------------------------------------------\nconst countriesRows = countries.map((c, index) => {\n  // Calculate trend based on CTR performance vs average\n  const ctr = parseFloat(c.ctr) || 0;\n  const avgCTR = parseFloat(stats.avgCTR) || 2.5;\n  let change, changeClass;\n  \n  // Assign deterministic trends based on country ranking\n  if (index === 0) { // Top country\n    change = \"+15%\"; changeClass = \"positive\";\n  } else if (index === 1) {\n    change = \"+8%\"; changeClass = \"positive\";\n  } else if (ctr > avgCTR * 1.2) {\n    change = `+${Math.round(ctr * 3)}%`; changeClass = \"positive\";\n  } else if (ctr > avgCTR * 0.8) {\n    change = \"0%\"; changeClass = \"neutral\";\n  } else {\n    change = `-${Math.round((avgCTR - ctr) * 2)}%`; changeClass = \"negative\";\n  }\n  \n  return `<tr>\n    <td>${getCountryName(c.country)}</td>\n    <td>${fmt(c.clicks)}</td>\n    <td>${fmt(c.impressions)}</td>\n    <td>${c.ctr || 'N/A'}</td>\n    <td class=\"change ${changeClass}\">${change}</td>\n  </tr>`;\n}).join('');\n\n// If no countries data, show a message row\nconst countriesRowsFinal = countriesRows || '<tr><td colspan=\"5\" style=\"text-align:center;color:var(--color-text-muted);\">No geographic data available</td></tr>';\n\n// -----------------------------------------------------------------------------\n// SECTION 11: DEVICE DATA PROCESSING\n// -----------------------------------------------------------------------------\n// Use real device data if available, otherwise create estimates from totals\nconst deviceData = devices.length > 0 \n  ? devices.map(d => ({\n      device: d.device.charAt(0).toUpperCase() + d.device.slice(1).toLowerCase(),\n      clicks: d.clicks || 0,\n      ctr: d.ctr || '0%',\n      position: d.position || 'N/A',\n      percentage: `${Math.round((d.clicks || 0) / (stats.clicks || 1) * 100)}%`\n    }))\n  : [\n      { device: 'Mobile', clicks: Math.round((stats.clicks || 0) * 0.61), ctr: '1.8%', position: '21.5', percentage: '61%' },\n      { device: 'Desktop', clicks: Math.round((stats.clicks || 0) * 0.35), ctr: '4.2%', position: '18.7', percentage: '35%' },\n      { device: 'Tablet', clicks: Math.round((stats.clicks || 0) * 0.04), ctr: '2.1%', position: '20.9', percentage: '4%' }\n    ];\n\nconst devicesRows = deviceData.map(d => {\n  const ctr = parseFloat(d.ctr) || 0;\n  const ctrClass = ctr > 3 ? 'position-good' : ctr > 1.5 ? 'position-opportunity' : 'position-poor';\n  return `<tr>\n    <td>${d.device}</td>\n    <td>${fmt(d.clicks)}</td>\n    <td class=\"${ctrClass}\">${d.ctr}</td>\n    <td>${d.position}</td>\n  </tr>`;\n}).join('');\n\n// -----------------------------------------------------------------------------\n// SECTION 12: CHART DATA PREPARATION\n// -----------------------------------------------------------------------------\nconst deviceLabels = deviceData.map(d => `${d.device} (${d.percentage})`);\nconst deviceDataJSON = deviceData.map(d => d.clicks);\nconst deviceColors = ['#1a365d', '#3182ce', '#90cdf4'];\n\n// Trend chart data - use real daily data or generate placeholder\nconst trendData = dailyData.length > 0 ? dailyData.slice(-30) : \n  Array.from({length: 30}, (_, i) => {\n    const date = new Date(now.getTime() - (29 - i) * 24 * 60 * 60 * 1000);\n    return {\n      date: date.toISOString().split('T')[0],\n      clicks: Math.floor(Math.random() * 10) + 1,\n      impressions: Math.floor(Math.random() * 200) + 50\n    };\n  });\n\nconst trendLabels = trendData.map(d => {\n  const date = new Date(d.date);\n  return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n});\nconst trendClicks = trendData.map(d => d.clicks || 0);\nconst trendImpressions = trendData.map(d => Math.round((d.impressions || 0) / 10));\n\n// -----------------------------------------------------------------------------\n// SECTION 13: REGIONAL CAROUSEL DATA\n// -----------------------------------------------------------------------------\nconst top4Countries = countries.slice(0, 4);\nconst regionLabels = top4Countries.map(c => {\n  const shortNames = { 'ind': 'India', 'usa': 'USA', 'gbr': 'UK', 'phl': 'Philippines', \n    'ita': 'Italy', 'are': 'UAE', 'can': 'Canada', 'aus': 'Australia' };\n  return shortNames[c.country?.toLowerCase()] || getCountryName(c.country);\n});\n\n// Calculate growth based on CTR performance\nconst regionGrowth = top4Countries.map((c, i) => {\n  const ctr = parseFloat(c.ctr) || 0;\n  if (i === 0) return 15; // Primary market\n  if (ctr > 3) return 22;\n  if (ctr > 2) return 8;\n  if (ctr > 1) return 0;\n  return -5;\n});\n\n// Generate carousel slides\nconst carouselSlides = top4Countries.map((c, i) => {\n  const growth = regionGrowth[i];\n  const growthClass = growth > 0 ? 'positive' : growth < 0 ? 'negative' : 'neutral';\n  const countryName = getCountryName(c.country);\n  \n  let detail = `${fmt(c.clicks)} clicks | ${fmt(c.impressions)} impressions | CTR: ${c.ctr}`;\n  \n  // Add contextual insights based on country\n  const countryLower = (c.country || '').toLowerCase();\n  if (countryLower === 'ind') {\n    detail += \" | Primary market. Consider Gujarat-focused content for local SEO.\";\n  } else if (countryLower === 'usa') {\n    detail += \" | High-value market. Add USD pricing and international shipping info.\";\n  } else if (countryLower === 'gbr') {\n    detail += \" | Stable interest. Focus on product quality and reliability messaging.\";\n  } else if (countryLower === 'phl') {\n    detail += \" | Fast-growing market. High CTR indicates strong interest.\";\n  } else if (countryLower === 'are') {\n    detail += \" | Premium market. Highlight luxury pool features and fast delivery.\";\n  }\n  \n  return `<div class=\"carousel-slide${i===0?' active':''}\" data-index=\"${i}\">\n    <div style=\"display: flex; align-items: center; gap: 12px; margin-bottom: 8px;\">\n        <span class=\"carousel-country\" style=\"margin-bottom: 0;\">${countryName}</span>\n        <span class=\"carousel-stat ${growthClass}\" \n              style=\"font-size: 24px; margin-bottom: 0;\">${growth > 0 ? '+' : ''}${growth}%</span>\n    </div>\n    <div class=\"carousel-detail\">${detail}</div>\n  </div>`;\n}).join('');\n\nconst carouselDots = top4Countries.map((_,i) => \n  `<div class=\"carousel-dot${i===0?' active':''}\" data-index=\"${i}\" onclick=\"goToSlide(${i})\"></div>`\n).join('');\n\n// Fallback for empty carousel\nconst carouselSlidesFinal = carouselSlides || '<div class=\"carousel-slide active\"><div class=\"carousel-country\">No Data</div><div class=\"carousel-detail\">Geographic data not available</div></div>';\n\n// -----------------------------------------------------------------------------\n// SECTION 14: EXECUTIVE SUMMARY AND INSIGHTS\n// -----------------------------------------------------------------------------\nconst execSummary = aiInsights.executive_summary || \n  `Your site bestwaygujarat.com has received ${fmt(stats.clicks)} total clicks from ${fmt(stats.impressions)} impressions over the past 6 months, with an average CTR of ${stats.avgCTR} and average position of ${stats.avgPosition}. Brand keywords like \"bestway\" and \"bestway swimming pool\" drive the majority of traffic, but there are significant opportunities to improve click-through rates on high-impression keywords. The site shows strong potential in the Indian market, which accounts for ${countries.length > 0 ? Math.round((countries[0]?.clicks || 0)/(stats.clicks || 1)*100) : 0}% of traffic.`;\n\n// Generate insight cards with clickable links to sections\nconst insightsCards = [\n  aiInsights.keyword_clusters ? \n  `<div class=\"insight-card issue\">\n    <div class=\"insight-label\">Keyword Performance</div>\n    <div class=\"insight-text\">Brand keywords perform best but product keywords need optimization. \"<a href=\"#keywords\" class=\"summary-link\" onclick=\"scrollToSection('keywords')\">${topKeywords[0]?.keyword || 'bestway'}</a>\" gets ${topKeywords[0]?.clicks || 0} clicks at position ${topKeywords[0]?.position || 'N/A'}.</div>\n  </div>` : \n  `<div class=\"insight-card issue\">\n    <div class=\"insight-label\">Low CTR Alert</div>\n    <div class=\"insight-text\">Overall CTR is ${stats.avgCTR}, below industry average. <a href=\"#keywords\" class=\"summary-link\" onclick=\"scrollToSection('keywords')\">High-impression keywords</a> need better meta descriptions.</div>\n  </div>`,\n  \n  `<div class=\"insight-card attention\">\n    <div class=\"insight-label\">Quick Win Opportunities</div>\n    <div class=\"insight-text\">${quickWins.length || topKeywords.filter(k => parseFloat(k.position) >= 4 && parseFloat(k.position) <= 15).length} keywords identified as <a href=\"#keywords\" class=\"summary-link\" onclick=\"scrollToSection('keywords')\">Quick Wins</a>. Optimize titles to improve CTR.</div>\n  </div>`,\n  \n  `<div class=\"insight-card opportunity\">\n    <div class=\"insight-label\">Geographic Strength</div>\n    <div class=\"insight-text\"><a href=\"#regions\" class=\"summary-link\" onclick=\"scrollToSection('regions')\">${getCountryName(countries[0]?.country)}</a> generates ${fmt(countries[0]?.clicks || 0)} clicks (${Math.round((countries[0]?.clicks || 0)/(stats.clicks || 1)*100)}% of total).</div>\n  </div>`,\n  \n  `<div class=\"insight-card opportunity\">\n    <div class=\"insight-label\">Page Potential</div>\n    <div class=\"insight-text\"><a href=\"#pages\" class=\"summary-link\" onclick=\"scrollToSection('pages')\">Top pages</a> have optimization opportunities. Homepage gets ${pages[0]?.clicks || 0} clicks.</div>\n  </div>`\n].join('');\n\n// Generate recommendations from AI or defaults\nconst recsHTML = (aiInsights
Pro

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

About this workflow

SEO-FLOW. Uses httpRequest. Event-driven trigger; 35 nodes.

Source: https://github.com/bionicop/n8n-seo-flow/blob/e99e0b7220cafaf6aca56f0581d85fae496f614a/workflows/seo-flow.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b

Trello Trigger, HTTP Request
Web Scraping

02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.

Execute Workflow Trigger, HTTP Request, Sea Table
Web Scraping

This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t

Execute Command, Read Write File, HTTP Request +3
Web Scraping

[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.

HTTP Request, GitHub, Stop And Error +1
Web Scraping

[](https://youtu.be/c7yCZhmMjtI)

HTTP Request, GitHub, Stop And Error +1