AutomationFlowsMarketing & Ads › Automate SEO Reporting with Google Search Console, Ga4, and Google Sheets

Automate SEO Reporting with Google Search Console, Ga4, and Google Sheets

ByAdam Gałęcki @agalecki on n8n.io

This workflow automates comprehensive SEO reporting by: Extracting keyword rankings and page performance from Google Search Console. Gathering organic reach metrics from Google Analytics. Analyzing internal and external article links. Tracking keyword position changes (gains and…

Event trigger★★★★★ complexityAI-powered38 nodesHTTP RequestGoogle SheetsGoogle AnalyticsOpenAI
Marketing & Ads Trigger: Event Nodes: 38 Complexity: ★★★★★ AI nodes: yes Added:

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

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

The workflow JSON

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

Download .json
{
  "id": "TEMPLATE_WORKFLOW_ID",
  "name": "SEO Report Template",
  "tags": [],
  "nodes": [
    {
      "id": "f64b105d-7cd7-4dca-b4c5-231214af3b25",
      "name": "When clicking 'Test workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -5744,
        -2080
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "5ebfa39d-b8c5-4229-94c5-867f2f1ed551",
      "name": "Sticky Note - External Articles",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -7472,
        -4768
      ],
      "parameters": {
        "color": 5,
        "width": 1420,
        "height": 364,
        "content": "## External Articles Reporting\n**Setup Instructions:**\n1. Add article URLs in the Data Sheet;\n2. Update the report document link in node \"Import Data to Report 3\";\n3. Verify the accuracy of submitted data."
      },
      "typeVersion": 1
    },
    {
      "id": "2a128afc-8cef-4b0e-a530-ca72a2b67f5c",
      "name": "Sticky Note - Internal Articles",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -7008,
        -4288
      ],
      "parameters": {
        "color": 5,
        "width": 1920,
        "height": 400,
        "content": "## Internal Articles Reporting\n**Setup Instructions:**\n1. Add article links in the Data Sheet;\n2. Update the report document link in node \"Import Data to Report 2\";\n3. Verify the accuracy of submitted data;\n4. Manually add monthly search volume in the report column."
      },
      "typeVersion": 1
    },
    {
      "id": "0d7b4aeb-571d-43da-a508-f8dfce67f592",
      "name": "Sticky Note - Organic Reach",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -6640,
        -3792
      ],
      "parameters": {
        "color": 5,
        "width": 720,
        "height": 364,
        "content": "## Organic Reach Reporting\n**Setup Instructions:**\n1. Set date range in node \"Import from Google Analytics 1\";\n2. Update report document link in node \"Import to Report 1\"."
      },
      "typeVersion": 1
    },
    {
      "id": "f2b230a9-bfc3-45f5-bf9b-d8d8741f662c",
      "name": "Convert Dots to Commas 2",
      "type": "n8n-nodes-base.code",
      "position": [
        -5664,
        -3200
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n    return {\n        json: {\n            ...item.json,\n            CTR: item.json.CTR.toString().replace('.', ','),\n            \"Average Position\": item.json[\"Average Position\"].toString().replace('.', ',')\n        }\n    };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "7eeb7754-36ce-41eb-8b38-ede9c2eeb5a1",
      "name": "Export Keywords from GSC 2",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -6128,
        -3200
      ],
      "parameters": {
        "url": "https://www.googleapis.com/webmasters/v3/sites/https%3A%2F%2Fexample.com/searchAnalytics/query",
        "method": "POST",
        "options": {},
        "jsonBody": "{\n  \"startDate\": \"2025-11-01\",\n  \"endDate\": \"2025-11-25\",\n  \"dimensions\": [\"query\"],\n  \"rowLimit\": 1000,\n  \"startRow\": 0,\n  \"aggregationType\": \"byProperty\",\n  \"dimensionFilterGroups\": []\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "d8e71fbf-15f9-4902-8611-d4aba111924d",
      "name": "Create Table 2",
      "type": "n8n-nodes-base.code",
      "position": [
        -5888,
        -3200
      ],
      "parameters": {
        "jsCode": "return $json.rows.map(row => ({\n    \"Keyword\": row.keys[0],\n    \"Clicks\": row.clicks,\n    \"Impressions\": row.impressions,\n    \"CTR\": row.ctr,\n    \"Average Position\": row.position\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "8d4aeef9-1a87-461c-bc41-355dd97b14a4",
      "name": "Organize Table 2",
      "type": "n8n-nodes-base.set",
      "position": [
        -5456,
        -3200
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5919caa6-e750-43fc-893b-64c923090f7a",
              "name": "Keyword",
              "type": "string",
              "value": "={{$json[\"Keyword\"]}}"
            },
            {
              "id": "30fb6df1-ff2d-4911-97cd-92c36558baa8",
              "name": "Clicks",
              "type": "string",
              "value": "={{$json[\"Clicks\"]}}"
            },
            {
              "id": "cb1a7ed9-033a-4a3b-9006-9da3e74270b8",
              "name": "Impressions",
              "type": "string",
              "value": "={{$json[\"Impressions\"]}}"
            },
            {
              "id": "d8689677-7b0c-4aea-a664-217b7ca6031f",
              "name": "CTR",
              "type": "string",
              "value": "={{$json[\"CTR\"]}}"
            },
            {
              "id": "73195bc2-e3b7-4f1e-8747-4edcfe4cc324",
              "name": "Average Position",
              "type": "string",
              "value": "={{$json[\"Average Position\"]}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "f68b89f1-f8b5-4dd9-a355-0942e06ece59",
      "name": "Import to Report 2",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -5216,
        -3200
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Impressions",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Impressions",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "CTR",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "CTR",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Average Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Average Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "54795d4f-2b28-4a80-ac26-a9cadecd2c40",
      "name": "Sticky Note - Keyword Ranking",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -6224,
        -3360
      ],
      "parameters": {
        "color": 5,
        "width": 1300,
        "height": 364,
        "content": "## Keyword Ranking\n**Setup Instructions:**\n1. Set date range for report in node \"Export Keywords from GSC 2\";\n2. Reload authorization in node \"Export Keywords from GSC 2\";\n3. Update report document link in node \"Import to Report 2\"."
      },
      "typeVersion": 1
    },
    {
      "id": "3b6f8367-5fe6-4dcc-ae26-00b2d008f9ad",
      "name": "Convert Dots to Commas 3",
      "type": "n8n-nodes-base.code",
      "position": [
        -5200,
        -2768
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n    return {\n        json: {\n            ...item.json,\n            CTR: item.json.CTR.toString().replace('.', ','),\n            \"Average Position\": item.json[\"Average Position\"].toString().replace('.', ',')\n        }\n    };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "3b2bb1e7-26a8-496f-97a2-13fa6c914038",
      "name": "Export Keywords from GSC 3",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5664,
        -2768
      ],
      "parameters": {
        "url": "https://www.googleapis.com/webmasters/v3/sites/https%3A%2F%2Fexample.com/searchAnalytics/query",
        "method": "POST",
        "options": {},
        "jsonBody": "{\n  \"startDate\": \"2025-09-01\",\n  \"endDate\": \"2025-09-30\",\n  \"dimensions\": [\"page\"],\n  \"rowLimit\": 1000,\n  \"startRow\": 0,\n  \"dimensionFilterGroups\": []\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0af80ad4-7657-4416-8344-90109fae1e6f",
      "name": "Create Table 3",
      "type": "n8n-nodes-base.code",
      "position": [
        -5424,
        -2768
      ],
      "parameters": {
        "jsCode": "return $json.rows.map(row => ({\n    \"Page\": row.keys[0],\n    \"Clicks\": row.clicks,\n    \"Impressions\": row.impressions,\n    \"CTR\": row.ctr,\n    \"Average Position\": row.position\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "89698e0d-6d7c-4d81-b9a0-a4c9e5773143",
      "name": "Organize Table 3",
      "type": "n8n-nodes-base.set",
      "position": [
        -4992,
        -2768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5919caa6-e750-43fc-893b-64c923090f7a",
              "name": "Keyword",
              "type": "string",
              "value": "={{$json[\"Page\"]}}"
            },
            {
              "id": "30fb6df1-ff2d-4911-97cd-92c36558baa8",
              "name": "Clicks",
              "type": "string",
              "value": "={{$json[\"Clicks\"]}}"
            },
            {
              "id": "cb1a7ed9-033a-4a3b-9006-9da3e74270b8",
              "name": "Impressions",
              "type": "string",
              "value": "={{$json[\"Impressions\"]}}"
            },
            {
              "id": "d8689677-7b0c-4aea-a664-217b7ca6031f",
              "name": "CTR",
              "type": "string",
              "value": "={{$json[\"CTR\"]}}"
            },
            {
              "id": "73195bc2-e3b7-4f1e-8747-4edcfe4cc324",
              "name": "Average Position",
              "type": "string",
              "value": "={{$json[\"Average Position\"]}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "182f6345-97e7-48ab-933b-8af692ffa762",
      "name": "Sticky Note - Page Ranking",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -5760,
        -2944
      ],
      "parameters": {
        "color": 5,
        "width": 1300,
        "height": 384,
        "content": "## Page Ranking\n**Setup Instructions:**\n1. Set date range for report in node \"Export Keywords from GSC 3\";\n2. Reload authorization in node \"Export Keywords from GSC 3\";\n3. Update report document link in node \"Import to Report 3\"."
      },
      "typeVersion": 1
    },
    {
      "id": "bef4bd4e-74bf-49ba-b865-53d64a55528c",
      "name": "Import from Google Analytics 1",
      "type": "n8n-nodes-base.googleAnalytics",
      "position": [
        -6560,
        -3616
      ],
      "parameters": {
        "endDate": "2025-11-25T00:00:00",
        "dateRange": "custom",
        "returnAll": true,
        "startDate": "2025-11-01T00:00:00",
        "metricsGA4": {
          "metricValues": [
            {
              "name": "organicGoogleSearchClicks",
              "listName": "other"
            },
            {
              "name": "organicGoogleSearchImpressions",
              "listName": "other"
            },
            {
              "name": "organicGoogleSearchClickThroughRate",
              "listName": "other"
            },
            {
              "name": "organicGoogleSearchAveragePosition",
              "listName": "other"
            }
          ]
        },
        "propertyId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GA4_PROPERTY_ID"
        },
        "dimensionsGA4": {
          "dimensionValues": [
            {}
          ]
        },
        "additionalFields": {}
      },
      "typeVersion": 2
    },
    {
      "id": "ff6161f0-109a-44bd-99e1-59d65b0f0190",
      "name": "Format Data 7",
      "type": "n8n-nodes-base.code",
      "position": [
        -6368,
        -3616
      ],
      "parameters": {
        "jsCode": "// Sort dates from oldest to newest\nconst sortedItems = items.sort((a, b) => parseInt(a.json.date) - parseInt(b.json.date));\n\n// Initialize variables for summing\nlet totalClicks = 0;\nlet totalImpressions = 0;\nlet totalCtrSum = 0;\nlet totalPositionSum = 0;\nlet count = sortedItems.length;\n\n// Map to transform data and sum values\nconst formattedData = sortedItems.map(item => {\n    // Convert date from YYYYMMDD to DD-MM-YYYY\n    const rawDate = item.json.date;\n    const formattedDate = `${rawDate.slice(6, 8)}-${rawDate.slice(4, 6)}-${rawDate.slice(0, 4)}`;\n\n    // Get values and convert to numbers\n    const clicks = parseInt(item.json.organicGoogleSearchClicks, 10);\n    const impressions = parseInt(item.json.organicGoogleSearchImpressions, 10);\n    const ctr = parseFloat(item.json.organicGoogleSearchClickThroughRate) * 100;\n    const position = parseFloat(item.json.organicGoogleSearchAveragePosition);\n\n    // Sum values\n    totalClicks += clicks;\n    totalImpressions += impressions;\n    totalCtrSum += ctr;\n    totalPositionSum += position;\n\n    return {\n        json: {\n            \"Date\": formattedDate, \n            \"Clicks\": clicks, \n            \"Impressions\": impressions, \n            \"CTR (%)\": ctr.toFixed(2).replace(\".\", \",\"), \n            \"Average Position\": position.toFixed(2).replace(\".\", \",\")\n        }\n    };\n});\n\n// Calculate average CTR and position\nconst averageCtr = (totalCtrSum / count).toFixed(2).replace(\".\", \",\");\nconst averagePosition = (totalPositionSum / count).toFixed(2).replace(\".\", \",\");\n\n// Add \"Total\" row\nformattedData.push({\n    json: {\n        \"Date\": \"Total\", \n        \"Clicks\": totalClicks, \n        \"Impressions\": totalImpressions, \n        \"CTR (%)\": averageCtr, \n        \"Average Position\": averagePosition\n    }\n});\n\n// Return data to n8n\nreturn formattedData;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "70b908b4-b2a9-48f4-b3ca-ffda654e9be5",
      "name": "Import to Report 1",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -6144,
        -3616
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Date",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Impressions",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Impressions",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "CTR (%)",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "CTR (%)",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Average Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Average Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "ea579d0d-06d5-4c6f-a881-725dbc6a28d6",
      "name": "Export Links from Document 1",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -6896,
        -4080
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_DATA_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "3b9ef20e-a66e-4d37-9541-82fb3883a965",
      "name": "Import Data to Report 2",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -5360,
        -4080
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Article URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Article URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Publication Date",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Publication Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Main Keyword",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Main Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Reach",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Reach",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Internal Links",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Internal Links",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Link Labels",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Link Labels",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Link Attributes",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Link Attributes",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "0ee0d8b5-299d-41fe-8cca-cc94b4e9e71c",
      "name": "Analyze Links 1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -6624,
        -4080
      ],
      "parameters": {
        "url": "={{$json[\"URL\"]}}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2
    },
    {
      "id": "d51af504-9210-4ad1-aef6-8cc26153405f",
      "name": "Format Data to Table 1",
      "type": "n8n-nodes-base.code",
      "position": [
        -6288,
        -4080
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// 1\ufe0f\u20e3 **Check if data is available**\nif (!$json[\"data\"] || typeof $json[\"data\"] !== \"string\") {\n    throw new Error(\"No HTML data - make sure HTTP Request returns page content.\");\n}\n\n// 2\ufe0f\u20e3 **Get input HTML**\nlet inputData = $json[\"data\"];\n\nconsole.log(\"=== STARTING ANALYSIS ===\");\nconsole.log(\"HTML length:\", inputData.length);\n\n// 3\ufe0f\u20e3 **Finding 'entry-content' section with multiple strategies**\n\n// Strategy 1: Find exact position of <div class=\"entry-content\">\nlet entryContentStart = inputData.indexOf('<div class=\"entry-content\">');\nlet entryContent = \"\";\nlet foundMethod = \"\";\n\nif (entryContentStart !== -1) {\n    console.log(\"\u2705 Found <div class=\\\"entry-content\\\"> at position:\", entryContentStart);\n    \n    // Find end of this div - look for closing </div>\n    // But we need to be careful with nested divs\n    let currentPos = entryContentStart + '<div class=\"entry-content\">'.length;\n    let divCount = 1; // Start with 1 because we're already in a div\n    let endPos = -1;\n    \n    while (currentPos < inputData.length && divCount > 0) {\n        let nextDiv = inputData.indexOf('<div', currentPos);\n        let nextCloseDiv = inputData.indexOf('</div>', currentPos);\n        \n        if (nextCloseDiv === -1) break; // No more closing divs\n        \n        if (nextDiv !== -1 && nextDiv < nextCloseDiv) {\n            // Found opening div before closing\n            divCount++;\n            currentPos = nextDiv + 4;\n        } else {\n            // Found closing div\n            divCount--;\n            if (divCount === 0) {\n                endPos = nextCloseDiv;\n                break;\n            }\n            currentPos = nextCloseDiv + 6;\n        }\n    }\n    \n    if (endPos !== -1) {\n        entryContent = inputData.substring(entryContentStart + '<div class=\"entry-content\">'.length, endPos);\n        foundMethod = \"Strategy 1: Precise div parsing\";\n        console.log(\"\u2705 Extracted entry-content, length:\", entryContent.length);\n    }\n}\n\n// Fallback if we didn't find it or something went wrong\nif (!entryContent || entryContent.length < 100) {\n    console.log(\"\u274c Strategy 1 didn't work, trying fallback\");\n    let match = inputData.match(/<div class=\"entry-content\">([\\s\\S]*?)(?=<\\/div>)/);\n    if (match) {\n        entryContent = match[1];\n        foundMethod = \"Strategy 2: Regex fallback\";\n    } else {\n        // Last attempt - take everything between entry-content and footer\n        let start = inputData.indexOf('entry-content');\n        let end = inputData.indexOf('<footer', start);\n        if (start !== -1 && end !== -1) {\n            entryContent = inputData.substring(start, end);\n            foundMethod = \"Strategy 3: From entry-content to footer\";\n        } else {\n            throw new Error(\"Cannot find entry-content section in any way\");\n        }\n    }\n}\n\nconsole.log(\"Used strategy:\", foundMethod);\nconsole.log(\"entry-content length:\", entryContent.length);\nconsole.log(\"entry-content start:\", entryContent.slice(0, 300));\n\n// 4\ufe0f\u20e3 **Finding links in entry-content**\nconsole.log(\"\\n=== LINK ANALYSIS ===\");\n\nconst allLinks = [];\n\n// Very simple and effective regex for links\nconst linkRegex = /<a\\s[^>]*href=[\"']([^\"']+)[\"'][^>]*>((?:(?!<\\/a>)[\\s\\S])*)<\\/a>/gi;\n\nlet match;\nlet linkCount = 0;\n\nwhile ((match = linkRegex.exec(entryContent)) !== null && linkCount < 50) {\n    linkCount++;\n    \n    const href = match[1].trim();\n    const fullContent = match[2];\n    \n    console.log(`\\n--- Link ${linkCount} ---`);\n    console.log(\"URL:\", href);\n    console.log(\"Content:\", fullContent.slice(0, 100));\n    \n    // Extract clean anchor text\n    let anchorText = fullContent.replace(/<[^>]*>/g, \"\").trim();\n    anchorText = anchorText.replace(/\\s+/g, \" \").trim();\n    \n    // Check rel attribute in entire <a> tag\n    const fullTag = match[0];\n    const relMatch = fullTag.match(/rel=[\"']([^\"']+)[\"']/i);\n    const rel = relMatch ? relMatch[1] : \"follow\";\n    \n    console.log(\"Anchor text:\", anchorText);\n    console.log(\"Rel:\", rel);\n    \n    // \ud83d\udccc **VERY BROAD internal link filters**\n    // Replace 'example.com' with your actual domain\n    const domain = \"example.com\";\n    const isInternalLink = (\n        // All links containing your domain\n        href.includes(domain) ||\n        // Relative links\n        (href.startsWith(\"/\") && !href.startsWith(\"//\")) ||\n        // Hash links\n        href.startsWith(\"#\") ||\n        // Links without protocol starting with domain\n        (href.indexOf(\"://\") === -1 && href.indexOf(\"www.\") === -1 && !href.startsWith(\"mailto:\") && !href.startsWith(\"tel:\"))\n    );\n    \n    console.log(\"Is internal:\", isInternalLink);\n    \n    if (isInternalLink && anchorText && anchorText.length > 0) {\n        // Check duplicates\n        const isDuplicate = allLinks.some(existingLink => \n            existingLink.href === href && existingLink.anchorText === anchorText\n        );\n        \n        if (!isDuplicate) {\n            allLinks.push({\n                href,\n                anchorText,\n                rel\n            });\n            console.log(\"\u2705 ADDED TO LIST\");\n        } else {\n            console.log(\"\u274c DUPLICATE - skipped\");\n        }\n    } else {\n        console.log(\"\u274c REJECTED - doesn't meet criteria\");\n    }\n}\n\n// \u2705 **Link summary**\nconsole.log(`\\n=== LINK SUMMARY ===`);\nconsole.log(\"Total number of internal links found:\", allLinks.length);\n\nallLinks.forEach((link, index) => {\n    console.log(`${index + 1}. ${link.href} -> \"${link.anchorText}\" [${link.rel}]`);\n});\n\n// 5\ufe0f\u20e3 **Format results**\nconst internalLinks = allLinks.map(link => link.href).join(\", \");\nconst anchorTexts = allLinks.map(link => link.anchorText).join(\", \");\nconst linkAttributes = allLinks.map(link => link.rel).join(\", \");\n\n// 6\ufe0f\u20e3 **Get metadata**\nconsole.log(\"\\n=== GETTING METADATA ===\");\n\n// Title - first check h1 in entry-content\nlet title = \"No title\";\nconst titleMatch = entryContent.match(/<h1[^>]*>(.*?)<\\/h1>/i);\nif (titleMatch) {\n    title = titleMatch[1].replace(/<[^>]*>/g, \"\").trim();\n    console.log(\"Title from entry-content:\", title);\n} else {\n    // Fallback - title tag\n    const titleTagMatch = inputData.match(/<title[^>]*>(.*?)<\\/title>/i);\n    if (titleTagMatch) {\n        title = titleTagMatch[1].replace(/<[^>]*>/g, \"\").trim();\n        console.log(\"Title from title tag:\", title);\n    }\n}\n\n// Article URL\nconst canonicalMatch = inputData.match(/<link[^>]+rel=[\"']canonical[\"'][^>]+href=[\"']([^\"']+)[\"']/i);\nconst articleUrl = canonicalMatch ? canonicalMatch[1] : \"No link\";\nconsole.log(\"Article URL:\", articleUrl);\n\n// Publication date\nconst publishedDateMatch = inputData.match(/<meta[^>]+property=[\"']article:published_time[\"'][^>]+content=[\"']([^\"']+)[\"']/i);\nlet publishedDate = publishedDateMatch ? publishedDateMatch[1] : \"No date\";\nconsole.log(\"Publication date (raw):\", publishedDate);\n\nfunction formatDate(dateString) {\n    if (!dateString || dateString === \"No date\") return \"No date\";\n    \n    console.log(\"Formatting date - input:\", dateString);\n    \n    try {\n        // If date contains T or + (ISO format), try directly first\n        let date = new Date(dateString);\n        \n        // If it doesn't work, try to extract only date part\n        if (isNaN(date.getTime())) {\n            console.log(\"First attempt failed, trying alternative\");\n            \n            // Extract only YYYY-MM-DD part\n            const dateMatch = dateString.match(/(\\d{4})-(\\d{2})-(\\d{2})/);\n            if (dateMatch) {\n                const [, year, month, day] = dateMatch;\n                date = new Date(year, month - 1, day); // month-1 because JavaScript counts months from 0\n                console.log(\"Created date from parts:\", year, month, day);\n            }\n        }\n        \n        if (isNaN(date.getTime())) {\n            console.log(\"All date parsing attempts failed\");\n            return \"Invalid date\";\n        }\n        \n        const day = String(date.getDate()).padStart(2, '0');\n        const month = String(date.getMonth() + 1).padStart(2, '0');\n        const year = String(date.getFullYear()).slice(2);\n        \n        console.log(`Formatted date: ${day}-${month}-${year}`);\n        return `${day}-${month}-${year}`;\n        \n    } catch (error) {\n        console.log(\"Date formatting error:\", error);\n        return \"Date formatting error\";\n    }\n}\n\npublishedDate = formatDate(publishedDate);\nconsole.log(\"Publication date (formatted):\", publishedDate);\n\n// Keywords\nconst keywordsMatch = inputData.match(/<meta[^>]+name=[\"']keywords[\"'][^>]+content=[\"']([^\"']+)[\"']/i);\nconst keywords = keywordsMatch ? keywordsMatch[1] : \"No keywords\";\nconsole.log(\"Keywords:\", keywords);\n\n// 7\ufe0f\u20e3 **Return results**\nconsole.log(\"\\n=== FINAL RESULTS ===\");\nconsole.log(\"Title:\", title);\nconsole.log(\"Article URL:\", articleUrl);\nconsole.log(\"Published Date:\", publishedDate);\nconsole.log(\"Keywords:\", keywords);\nconsole.log(\"Internal Links:\", internalLinks);\nconsole.log(\"Anchor Texts:\", anchorTexts);\nconsole.log(\"Link Attributes:\", linkAttributes);\n\nreturn {\n    title,\n    articleUrl,\n    publishedDate,\n    keywords,\n    volume: \"\",\n    internalLinks,\n    anchorTexts,\n    linkAttributes\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "75f79f2d-4811-4ece-945b-9c6a0ce98183",
      "name": "Format Data for Table Paste 1",
      "type": "n8n-nodes-base.code",
      "position": [
        -5856,
        -4080
      ],
      "parameters": {
        "jsCode": "// Get input data from Google Sheets\nconst data = $input.all().map(item => item.json);\n\n// Map new column headers\nconst newHeaders = {\n    title: \"Title\",\n    articleUrl: \"Article URL\",\n    publishedDate: \"Publication Date\",\n    keywords: \"Main Keyword\",\n    volume: \"Reach\",\n    internalLinks: \"Internal Links\",\n    anchorTexts: \"Link Labels\",\n    linkAttributes: \"Link Attributes\"\n};\n\n// Function to convert date from ISO to DD-MM-YY format - FIXED\nfunction formatDate(dateString) {\n    if (!dateString || dateString === \"No date\" || dateString === \"\") {\n        console.log(\"No date to format\");\n        return \"\"; // If no date, return empty string\n    }\n    \n    console.log(\"Formatting date in processing - input:\", dateString, \"type:\", typeof dateString);\n    \n    // Check if date is already in DD-MM-YY format\n    if (typeof dateString === \"string\" && /^\\d{2}-\\d{2}-\\d{2}$/.test(dateString)) {\n        console.log(\"Date already in DD-MM-YY format, returning unchanged:\", dateString);\n        return dateString;\n    }\n    \n    try {\n        // If date contains T or + (ISO format), try directly first\n        let date = new Date(dateString);\n        \n        // If it doesn't work, try to extract only date part\n        if (isNaN(date.getTime())) {\n            console.log(\"First attempt failed, trying alternative\");\n            \n            // Extract only YYYY-MM-DD part\n            const dateMatch = dateString.match(/(\\d{4})-(\\d{2})-(\\d{2})/);\n            if (dateMatch) {\n                const [, year, month, day] = dateMatch;\n                date = new Date(year, month - 1, day); // month-1 because JavaScript counts months from 0\n                console.log(\"Created date from parts:\", year, month, day);\n            }\n        }\n        \n        // If still no valid date\n        if (isNaN(date.getTime())) {\n            console.log(\"All date parsing attempts failed for:\", dateString);\n            return dateString; // Return original value instead of empty string\n        }\n        \n        const day = String(date.getDate()).padStart(2, '0');\n        const month = String(date.getMonth() + 1).padStart(2, '0');\n        const year = String(date.getFullYear()).slice(2);\n        \n        const formattedResult = `${day}-${month}-${year}`;\n        console.log(`Formatted date: ${formattedResult}`);\n        return formattedResult;\n        \n    } catch (error) {\n        console.log(\"Date formatting error:\", error, \"- returning original value\");\n        return dateString; // Return original value instead of empty string\n    }\n}\n\n// Process data and format rows\nconst processedData = [];\n\ndata.forEach((row, rowIndex) => {\n    console.log(`\\nProcessing row ${rowIndex + 1}:`, row);\n    \n    const internalLinks = row.internalLinks ? row.internalLinks.split(\", \") : [];\n    const anchorTexts = row.anchorTexts ? row.anchorTexts.split(\", \") : [];\n    const linkAttributes = row.linkAttributes ? row.linkAttributes.split(\", \") : [];\n    \n    console.log(\"Found internal links:\", internalLinks.length);\n    console.log(\"Publication date (raw):\", row.publishedDate);\n    \n    // Convert date to DD-MM-YY\n    const formattedDate = formatDate(row.publishedDate);\n    console.log(\"Publication date (formatted):\", formattedDate);\n    \n    // First row contains all main data\n    if (internalLinks.length > 0) {\n        internalLinks.forEach((link, index) => {\n            const processedRow = {\n                [newHeaders.title]: index === 0 ? row.title : \"\",  // Only first row has title\n                [newHeaders.articleUrl]: index === 0 ? row.articleUrl : \"\",  // Article URL\n                [newHeaders.publishedDate]: index === 0 ? formattedDate : \"\",  // Formatted date\n                [newHeaders.keywords]: index === 0 ? row.keywords : \"\",  // Only first row has keyword\n                [newHeaders.volume]: index === 0 ? row.volume || \"\" : \"\",  // Only first row has reach\n                [newHeaders.internalLinks]: link.trim(),  // Each link gets separate row\n                [newHeaders.anchorTexts]: anchorTexts[index] ? anchorTexts[index].trim() : \"\",  // Related link label\n                [newHeaders.linkAttributes]: linkAttributes[index] || \"follow\"  // Default follow\n            };\n            \n            processedData.push(processedRow);\n            console.log(`Added row ${index + 1} for link:`, link.slice(0, 50));\n        });\n    } else {\n        // If no internal links, add row with only basic data\n        const processedRow = {\n            [newHeaders.title]: row.title,\n            [newHeaders.articleUrl]: row.articleUrl,  // Added article URL\n            [newHeaders.publishedDate]: formattedDate,\n            [newHeaders.keywords]: row.keywords,\n            [newHeaders.volume]: row.volume || \"\",\n            [newHeaders.internalLinks]: \"\",\n            [newHeaders.anchorTexts]: \"\",\n            [newHeaders.linkAttributes]: \"\"\n        };\n        \n        processedData.push(processedRow);\n        console.log(\"Added row without internal links\");\n    }\n});\n\nconsole.log(`\\n=== PROCESSING SUMMARY ===`);\nconsole.log(\"Total number of processed rows:\", processedData.length);\nconsole.log(\"First 3 processed rows:\", processedData.slice(0, 3));\n\n// Return processed data in appropriate format\nreturn processedData;"
      },
      "typeVersion": 2
    },
    {
      "id": "9fc35392-f86d-489a-85fe-50ae03a7ee79",
      "name": "Analyze External Links 1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -7184,
        -4592
      ],
      "parameters": {
        "url": "={{$json[\"URL\"]}}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "5160562a-ef03-43f2-916d-758dddcecb9b",
      "name": "Format Data for Table 1",
      "type": "n8n-nodes-base.code",
      "position": [
        -6976,
        -4592
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const html = $json[\"data\"] || \"\"; // Get HTML from input data\n\n// Debugging: See what's in html\nconsole.log(\"HTML contains:\", html);\n\n// If HTML is empty, return error\nif (!html || html.trim() === \"\") {\n    return {\n        error: \"No data retrieved from HTTP Request. Check configuration.\",\n        url: $json[\"url\"] || \"No URL\"\n    };\n}\n\n// Extract article title\nconst titleMatch = html.match(/<title>(.*?)<\\/title>/i);\nconst title = titleMatch ? titleMatch[1].trim() : \"Title not found\";\n\n// Extract canonical link\nconst canonicalMatch = html.match(/<link[^>]+rel=[\"']canonical[\"'][^>]+href=[\"']([^\"']+)[\"']/i);\nconst canonical = canonicalMatch ? canonicalMatch[1] : \"Canonical not found\";\n\n// Extract article publication date\nconst dateMatch = html.match(/<meta[^>]+property=[\"']article:published_time[\"'][^>]+content=[\"']([^\"']+)[\"']/i);\nlet publishDate = dateMatch ? dateMatch[1] : \"\";\n\n// \ud83d\udd04 **Convert date format to DD-MM-YY**\nfunction formatDate(dateString) {\n    if (!dateString) return \"\"; // If no date, return empty string\n    const date = new Date(dateString);\n    const day = String(date.getDate()).padStart(2, '0');\n    const month = String(date.getMonth() + 1).padStart(2, '0'); // Months start from 0\n    const year = String(date.getFullYear()).slice(2); // Get only last two digits of year\n    return `${day}-${month}-${year}`;\n}\n\n// Convert date to new format\npublishDate = formatDate(publishDate);\n\n// Extract links containing your domain\n// Replace 'example.com' with your actual domain\nconst domain = \"example.com\";\nconst links = [];\nconst regex = /<a[^>]+href=\"([^\"]+)\"[^>]*>(.*?)<\\/a>/g;\nlet match;\n\nwhile ((match = regex.exec(html)) !== null) {\n    const href = match[1];\n    const text = match[2].trim();\n\n    // Check rel=\"nofollow\" attribute\n    const relMatch = match[0].match(/rel=[\"']nofollow[\"']/i);\n    const relType = relMatch ? \"nofollow\" : \"follow\";\n\n    if (href.includes(domain) || text.includes(domain)) {\n        links.push({ href, text, rel: relType });\n    }\n}\n\n// Return data as single object\nreturn {\n    title,\n    canonical,\n    publishDate, // New field with date in DD-MM-YY format\n    links: links.length ? links : [{ message: `No links containing '${domain}' found in provided HTML.` }],\n    url: $json[\"url\"] || \"No URL\"\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ecde8070-6d59-4544-858f-86fc639768e7",
      "name": "Proper Data Formatting",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        -6800,
        -4592
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o",
          "cachedResultName": "GPT-4O"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=Input data:\nArticle title: {{$json[\"title\"]}}\nCanonical link: {{$json[\"canonical\"]}}\nPublication date: {{$json[\"publishDate\"]}}\n\nLinks in article:\n{{ $json.links.map(link => `- ${link.text} (${link.href}) [${link.rel}]`).join('\\n') }}\n\nTransform this data into tabular format ready to save in Google Sheets:\n\n1. First row: Article title, Canonical link, Publication date.\n2. Next rows: Link, Link description, Follow/nofollow attribute.\n3. Each value separated by comma.\n4. Return data in CSV format, where each line represents one table row."
            }
          ]
        }
      },
      "typeVersion": 1.8
    },
    {
      "id": "1ed5cda6-9aa0-40be-acbc-2c933b5141de",
      "name": "Format Data Under Table 1",
      "type": "n8n-nodes-base.code",
      "position": [
        -6464,
        -4592
      ],
      "parameters": {
        "jsCode": "// Initialize output array\nconst output = [];\n\n// Iterate over each input data element\nitems.forEach(item => {\n    // Get CSV data from message.content field\n    const content = item.json.message.content;\n\n    // Remove Markdown markers and split data into lines\n    const lines = content.replace(/```csv/g, '').trim().split('\\n');\n\n    // Check if there are enough lines\n    if (lines.length < 4) {\n        throw new Error(\"Invalid input data format. Check data source.\");\n    }\n\n    // Separate first part (headers and article title) from links\n    const articleInfo = lines.slice(1, 2)[0]?.split(',');\n    const links = lines.slice(3);\n\n    // Check if headers are correct\n    if (!articleInfo || articleInfo.length < 3) {\n        throw new Error(\"Missing article title, canonical link or publication date.\");\n    }\n\n    // Get article title, canonical link and publication date\n    const title = articleInfo[0]?.trim() || \"No title\";\n    const canonical = articleInfo[1]?.trim() || \"No canonical link\";\n    const publishDate = articleInfo[2]?.trim() || \"\"; // New field\n\n    // Process each link and link description\n    links.forEach((linkRow, index) => {\n        if (linkRow.trim() === \"\") return; // Ignore empty rows\n\n        const [link, description, relType] = linkRow.split(',');\n\n        // Add to results with appropriate data\n        output.push({\n            \"Article Title\": index === 0 ? title : \"\", \n            \"Canonical Link\": index === 0 ? canonical : \"\", \n            \"Publication Date\": index === 0 ? publishDate : \"\", // New field\n            \"Link\": link?.trim() || \"No link\",\n            \"Link Description\": description?.trim() || \"No description\",\n            \"Attribute\": relType?.trim() || \"follow\" // New field\n        });\n    });\n});\n\n// Return result data\nreturn output.map(row => ({ json: row }));"
      },
      "typeVersion": 2
    },
    {
      "id": "45992637-7145-4374-9dfa-08d6196a5bf0",
      "name": "Import Data to Report 3",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -6272,
        -4592
      ],
      "parameters": {
        "columns": {
          "value": {
            "Article Title": "={{$json[\"Article Title\"]}}",
            "Publication Date": "={{$json[\"Publication Date\"]}}"
          },
          "schema": [
            {
              "id": "Article Title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Article Title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Canonical Link",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Canonical Link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Publication Date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Publication Date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Link",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Link Description",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Link Description",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Attribute",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Attribute",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "ddd41e66-02c9-4692-9659-647fbdd1d9ac",
      "name": "Import to Report 3",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -4752,
        -2768
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Clicks",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Clicks",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Impressions",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Impressions",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "CTR",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "CTR",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Average Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Average Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "ac4ae239-4cd2-44b3-ac05-ac0529f3164c",
      "name": "Format Data 12",
      "type": "n8n-nodes-base.code",
      "position": [
        -4832,
        -2192
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n    const convertDotsToCommas = (value) => {\n        // Check if the value is a number or can be converted to a number\n        const num = Number(value);\n        if (!isNaN(num) && typeof value === 'string') {\n            return value.replace('.', ',');\n        }\n        // If it's already a number, convert to string and replace\n        if (typeof value === 'number') {\n            return value.toString().replace('.', ',');\n        }\n        return value;\n    };\n\n    return {\n        json: {\n            \"Keyword\": item.json[\"Keyword\"],\n            \"URL\": item.json[\"URL\"],\n            \"Avg. Monthly Searches\": item.json[\"Avg. Monthly Searches\"],\n            \"Position\": item.json[\"Position\"],\n            \"Previous Position\": item.json[\"Previous Position\"],\n            \"Position Change\": item.json[\"Position Change\"],\n            \"Estimated Traffic\": convertDotsToCommas(item.json[\"Estimated Traffic\"]),\n            \"Previous Estimated Traffic\": convertDotsToCommas(item.json[\"Previous Estimated Traffic\"]),\n            \"Estimated Traffic Change\": convertDotsToCommas(item.json[\"Estimated Traffic Change\"]),\n        }\n    };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "2da61380-ac2c-4b10-b368-a8bdb4620e9e",
      "name": "Format Data 13",
      "type": "n8n-nodes-base.code",
      "position": [
        -4848,
        -1712
      ],
      "parameters": {
        "jsCode": "return items.map(item => {\n    const convertDotsToCommas = (value) => {\n        // Check if the value is a number or can be converted to a number\n        const num = Number(value);\n        if (!isNaN(num) && typeof value === 'string') {\n            return value.replace('.', ',');\n        }\n        // If it's already a number, convert to string and replace\n        if (typeof value === 'number') {\n            return value.toString().replace('.', ',');\n        }\n        return value;\n    };\n\n    return {\n        json: {\n            \"Keyword\": item.json[\"Keyword\"],\n            \"URL\": item.json[\"URL\"],\n            \"Avg. Monthly Searches\": item.json[\"Avg. Monthly Searches\"],\n            \"Position\": item.json[\"Position\"],\n            \"Previous Position\": item.json[\"Previous Position\"],\n            \"Position Change\": item.json[\"Position Change\"],\n            \"Estimated Traffic\": convertDotsToCommas(item.json[\"Estimated Traffic\"]),\n            \"Previous Estimated Traffic\": convertDotsToCommas(item.json[\"Previous Estimated Traffic\"]),\n            \"Estimated Traffic Change\": convertDotsToCommas(item.json[\"Estimated Traffic Change\"]),\n        }\n    };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "77867ec9-6e2f-45a0-83b7-ce6891c4c17b",
      "name": "Export Data from Document 1",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -5056,
        -2192
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_DATA_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "9346647f-74f3-4c5e-b242-2fb8f2b7d3a0",
      "name": "Export Data from Document 2",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -5056,
        -1712
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_DATA_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "be44d212-6e2b-45e7-ab1b-0fe143515d7e",
      "name": "Import Data to Sheet 10",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -4608,
        -2192
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Avg. Monthly Searches",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Avg. Monthly Searches",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Previous Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Previous Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position Change",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Position Change",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Estimated Traffic",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Estimated Traffic",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Previous Estimated Traffic",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Previous Estimated Traffic",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Estimated Traffic Change",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Estimated Traffic Change",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SHEET_NAME"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "20f781ef-e427-430f-9a71-a3c0c427ad8a",
      "name": "Import Data to Sheet 11",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -4624,
        -1712
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [
            {
              "id": "Keyword",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Keyword",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "URL",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "URL",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Avg. Monthly Searches",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Avg. Monthly Searches",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Previous Position",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Previous Position",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Position Change",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Position Change",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Estimated Traffic",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Estimated Traffic",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Previous Estimated Traffic",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Previous Estimated Traffic",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Estimated Traffic Change",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Estimated Traffic Change",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
     
Pro

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

About this workflow

This workflow automates comprehensive SEO reporting by: Extracting keyword rankings and page performance from Google Search Console. Gathering organic reach metrics from Google Analytics. Analyzing internal and external article links. Tracking keyword position changes (gains and…

Source: https://n8n.io/workflows/11665/ — original creator credit. Request a take-down →

More Marketing & Ads workflows → · Browse all categories →

Related workflows

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

Marketing & Ads

This template automates the enrichment of business leads from a Google Sheet by: Triggering when a row is activated Searching for company information with Serper.dev Generating and validating potentia

HTTP Request, Google Sheets Trigger, Google Sheets
Marketing & Ads

The automation starts by retreiving the unused queries from a sheet, executes queries in the web using Serper API and extracts linkedin profiles of decision makers.

Google Sheets, HTTP Request, Stop And Error
Marketing & Ads

This workflow helps you automatically collect verified business leads from Google Search using SerpAPI — no coding required. It extracts company names, websites, emails, and phone numbers directly fro

HTTP Request, Google Sheets
Marketing & Ads

My workflow 11. Uses manualTrigger, splitOut, httpRequest, splitInBatches. Event-driven trigger; 14 nodes.

HTTP Request, OpenAI, Google Sheets +2
Marketing & Ads

Trending YouTube Videos copy. Uses googleSheets, openAi, httpRequest, stickyNote. Event-driven trigger; 12 nodes.

Google Sheets, OpenAI, HTTP Request +1