Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

If youโ€™ve ever conducted pagespeed audits, you know how typical they are and how much time it takes to capture the scores and analyse the results. The process of using PageSpeed Insights (PSI) and testing multiple URLs through it is painstakingly slow, and collating the scores along with recommendation analysis in a spreadsheet is incredibly tedious.

When I was new to measuring and analysing core web vital performance, I used to analyse URLs via the Pagespeed Insight online tool one by one. It used to take me hours to run all reports and compile results in a single sheet. I started using JS scripts on the browser after that, but it was still a repetitive process.

I tried automating this using Data Studio (previously Looker, previously Data Studio), but the connector that was available was a third-party developed connector and our corporation didnโ€™t approve creating a new contract and one with a rolling subscription (it was paid).

I started using Screaming Frog for a bit, but since itโ€™s not subscribed to by every agency or company, I needed a zero-cost solution. Also, for a few agencies I consulted with previously, they didnโ€™t have a way to do it except to use the Pagespeed API and develop a proprietary solution around it.

So the problem statement was this: How do you get PageSpeed Insights results for multiple URLs in the least time and least clicks possible?

Solution: Build a n8n workflow around the PSI API and fetch those results for multiple URLs in a neat CSV file.

Automating Core Web Vital Score Collection via PageSpeed Insights API using n8n

n8n is a great tool for this automation. You just need a few nodes โ€“ first to validate the user input, then create a loop to process API requests, and then finally merge all data in a clean CSV file.

Phase 1: Input and validation

The first phase focuses on taking in user input from the chat window. After the input is done, it normalises and validates the URL. An additional check after this node makes sure only proper URLs go further into the workflow and spit out the non-workable data.

In addition to basic validation, this phase also contains a node to extract cleaned URLs and split them for the loop process to receive one URL per run.

n8n pagespeed audit bulk input validation : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

Phase 2: URL Reachability checks

Because we will use Pagespeed API later in our workflow, along with the API key received from Google Cloud Console, we wouldnโ€™t want to waste our budget or run into issues by providing bad URLs to the API. Just for this purpose, I added additional checks to ensure all input URLs pass the first basic validation and then also pass the reachability check. Which means each URL returns an HTTP 200 status code. The other URLs are left behind, and only the reachable URLs are passed on to the API processing phase.

n8n pagespeed audit bulk url reachability : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

Phase 3: Pagespeed API Call Loop

After we have the working URLs from the list, the Loop node begins its function of feeding one URL to the nodes that are attached to it.

n8n pagespeed audit bulk pagespeed api : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

Phase 3 is a simple one.

The loop node sends one URL from the node, and the Pagespeed API node receives the URL with parameters that weโ€™ve set here. And then there is a wait node to let the API call cool off, to avoid any abuse from the system, and then the data is received in JSON to the Extract Pagespeed Data node. The last node in this phase extracts data from JSON so that only the data we want moves through, and the noise is removed.

The API credentials are set up in the n8n instance, so the API keys are not hardcoded. If youโ€™re using your workflow for your own PageSpeed Audits, you can simply get a free API key from GCP.

Here is the URL that Iโ€™ve used to fetch API results:

https://www.googleapis.com/pagespeedonline/v5/runPagespeed?&strategy=mobile&category=performance&category=seo&category=best-practices&category=accessibility

and here is the overall setup of the node from a quick glance:

n8n pagespeed audit bulk pagespeed node setup : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

Phase 4: Output and Delivery

The final phase uses the combined output from the API call node, and merges the data into a nice and clean CSV file.

The CSV file is then uploaded to a very temporary hosting service, and the resultant cloud URL for downloading the CSV file is shared with the user in the chat interface.

n8n pagespeed audit bulk output delivery : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

This is the final output you get after entering 1 or 10 URLs in the workflow.

n8n pagespeed audit bulk chat output : Automate Core Web Vitals: Bulk Export PageSpeed Insights to CSV Using n8n

Download or Copy (JSON) this Free n8n Workflow

Download the workflow file here (Json)

You can also copy and paste this JSON code directly into your n8n instance. (click to expand)
{
  "name": "Bulk Google PageSpeed Insights to CSV",
  "nodes": [
    {
      "parameters": {
        "jsCode": "const rawInput = $input.first().json.chatInput || \"\";\nconst rawUrls = rawInput.split(/[\\s,]+/).filter(Boolean);\n\nlet validUrls = [];\nlet skipped = [];\nconst urlRegex = /^(https?:\\/\\/)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([\\/\\w \\.-]*)*\\/?$/i;\n\nfor (let url of rawUrls) {\n  if (!urlRegex.test(url)) {\n    skipped.push(url);\n    continue;\n  }\n  let clean = url.toLowerCase();\n  if (!clean.startsWith('http://') && !clean.startsWith('https://')) {\n    clean = 'https://' + clean;\n  }\n  if (!validUrls.includes(clean)) {\n    validUrls.push(clean);\n  }\n}\n\nlet warning = \"\";\nif (validUrls.length > 10) {\n  warning = \"โš ๏ธ Only the first 10 URLs will be processed. Remaining URLs have been skipped.\";\n  validUrls = validUrls.slice(0, 10);\n}\n\nreturn [{\n  json: {\n    valid_urls: validUrls,\n    skipped_input: skipped,\n    warning: warning,\n    total_valid: validUrls.length\n  }\n}];"
      },
      "id": "23b42988-ba4f-4acb-9b45-e3c30d971749",
      "name": "Parse & Normalise URLs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        672,
        192
      ]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.total_valid }}",
              "operation": "equal"
            }
          ]
        }
      },
      "id": "14ebe575-a8f1-4e01-85b9-a38534818ba5",
      "name": "Early Exit Check",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1024,
        192
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{ json: { output: \"โŒ No valid URLs were found in your input. Please enter at least one properly formatted URL (e.g. https://example.com) and try again.\" } }];"
      },
      "id": "899e3008-be7f-4bde-9d7e-758a80261f59",
      "name": "Respond - No Valid URLs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        176
      ]
    },
    {
      "parameters": {
        "fieldToSplitOut": "valid_urls",
        "options": {}
      },
      "id": "18bc2888-b2b0-4117-aef7-27b6cc5d4c84",
      "name": "Split Valid URLs",
      "type": "n8n-nodes-base.itemLists",
      "typeVersion": 3,
      "position": [
        1712,
        208
      ]
    },
    {
      "parameters": {
        "method": "HEAD",
        "url": "={{ $json.valid_urls }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "77358009-b3b8-414e-99a4-6c96adc2c68c",
      "name": "Reachability Check",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        2208,
        208
      ],
      "alwaysOutputData": true,
      "continueOnFail": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// 1. Get all items from the current node (Reachability Check)\nconst items = $input.all();\n\n// 2. Map through them to output only the two fields you want\nreturn items.map((item, index) => {\n  \n  // REACHBACK: Get the URL from the Split node using the index\n  const splitNodeData = $('Split Valid URLs').all();\n  const url = splitNodeData[index]?.json?.valid_urls || \"URL Not Found\";\n  \n  // DATA EXTRACTION: Pull status from the Reachability node\n  const statusCode = item.json.statusCode || item.json.metadata?.statusCode;\n\n  return {\n    json: {\n      valid_urls: url,\n      status: statusCode\n    }\n  };\n});"
      },
      "id": "00c80806-8d5d-4c69-a2ee-3c475738a860",
      "name": "Filter Reachable",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2592,
        192
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.status }}",
              "operation": "notEqual",
              "value2": "={{200}}"
            }
          ]
        }
      },
      "id": "7ba453a7-d60f-49ed-a30c-fa4e26ce318e",
      "name": "Second Early Exit",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        2928,
        192
      ]
    },
    {
      "parameters": {
        "fieldToSplitOut": "valid_urls",
        "options": {}
      },
      "id": "006d5828-2f75-4841-9cb7-3ded070c854d",
      "name": "Split Reachable URLs",
      "type": "n8n-nodes-base.itemLists",
      "typeVersion": 3,
      "position": [
        3712,
        208
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "44703885-1d21-45c6-8e55-438e615d83d1",
      "name": "Loop",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        4064,
        208
      ]
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?&strategy=mobile&category=performance&category=seo&category=best-practices&category=accessibility",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpQueryAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $json.valid_urls }}"
            }
          ]
        },
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 3000
            }
          },
          "queryParameterArrays": "repeat",
          "timeout": 90000
        }
      },
      "id": "5b1325c3-3e47-485a-a299-e65a9def2452",
      "name": "PageSpeed API",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        4432,
        224
      ],
      "credentials": {
        "httpQueryAuth": {
          "id": "x81eMy4AdIdflW7v",
          "name": "Query Auth account"
        }
      },
      "continueOnFail": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nlet results = [];\n\nfor (const entry of items) {\n  const item = entry.json;\n  const lh = item.lighthouseResult;\n\n  // 1. RESILIENCE: Catch missing Lighthouse data\n  if (!lh) {\n    results.push({\n      \"Address\": item.id || \"Unknown URL\",\n      \"PSI Request Status\": \"Error: No Lighthouse Data\",\n      \"Performance Score\": 0\n    });\n    continue;\n  }\n\n  const audits = lh.audits || {};\n  const categories = lh.categories || {};\n\n  // HELPER: Resource Summary\n  const getRes = (type) => {\n    const summary = audits['resource-summary'];\n    const resItems = (summary && summary.details && summary.details.items) ? summary.details.items : [];\n    const res = resItems.find(i => i.resourceType === type);\n    return { size: res ? res.transferSize : 0, count: res ? res.requestCount : 0 };\n  };\n\n  // HELPER: Savings (Updated for v13 Insight Schema)\n  const getSav = (id) => {\n    const audit = audits[id];\n    if (!audit) return { bytes: 0, ms: 0 };\n    \n    const details = audit.details || {};\n    const metricSavings = audit.metricSavings || {};\n    const debugData = details.debugData || {};\n\n    // Logic to handle varying locations of savings data in modern audits\n    const bytes = details.overallSavingsBytes || details.wastedBytes || debugData.wastedBytes || 0;\n    const ms = details.overallSavingsMs || details.wastedMs || audit.numericValue || metricSavings.FCP || metricSavings.LCP || 0;\n    \n    return { bytes, ms };\n  };\n\n  // HELPER: Category Mapper\n  const getCat = (score) => {\n    if (score === null || score === undefined) return \"N/A\";\n    if (score >= 0.9) return \"Good\";\n    if (score >= 0.5) return \"Needs Improvement\";\n    return \"Poor\";\n  };\n\n  // Pre-fetch nested insight objects to avoid long logical chains\n  const docLatency = audits['document-latency-insight'];\n  const lcpDiscovery = audits['lcp-discovery-insight'];\n  const netTree = audits['network-dependency-tree-insight'];\n  const thirdParties = audits['third-parties-insight'];\n\n  results.push({\n    \"Address\": lh.finalUrl || item.id,\n    \"PSI Request Status\": \"Success\",\n    \"Performance Score\": Math.round((categories.performance ? categories.performance.score : 0) * 100),\n\n    // LAB METRICS\n    \"First Contentful Paint Time (ms)\": audits['first-contentful-paint'] ? audits['first-contentful-paint'].numericValue : 0,\n    \"First Contentful Paint Score\": Math.round((audits['first-contentful-paint'] ? audits['first-contentful-paint'].score : 0) * 100),\n    \"Speed Index Time (ms)\": audits['speed-index'] ? audits['speed-index'].numericValue : 0,\n    \"Speed Index Score\": Math.round((audits['speed-index'] ? audits['speed-index'].score : 0) * 100),\n    \"Largest Contentful Paint Time (ms)\": audits['largest-contentful-paint'] ? audits['largest-contentful-paint'].numericValue : 0,\n    \"Largest Contentful Paint Score\": Math.round((audits['largest-contentful-paint'] ? audits['largest-contentful-paint'].score : 0) * 100),\n    \"Time to Interactive (ms)\": audits['interactive'] ? audits['interactive'].numericValue : 0,\n    \"Time to Interactive Score\": Math.round((audits['interactive'] ? audits['interactive'].score : 0) * 100),\n    \"Max Potential First Input Delay (ms)\": audits['max-potential-fid'] ? audits['max-potential-fid'].numericValue : 0,\n    \"Max Potential First Input Delay Score\": Math.round((audits['max-potential-fid'] ? audits['max-potential-fid'].score : 0) * 100),\n    \"Total Blocking Time (ms)\": audits['total-blocking-time'] ? audits['total-blocking-time'].numericValue : 0,\n    \"Total Blocking Time Score\": Math.round((audits['total-blocking-time'] ? audits['total-blocking-time'].score : 0) * 100),\n    \"Cumulative Layout Shift\": audits['cumulative-layout-shift'] ? audits['cumulative-layout-shift'].numericValue : 0,\n    \"Cumulative Layout Shift Score\": Math.round((audits['cumulative-layout-shift'] ? audits['cumulative-layout-shift'].score : 0) * 100),\n\n    // AGGREGATED SAVINGS\n    \"Total Size Savings (Bytes)\": (getSav('unused-javascript').bytes + getSav('unused-css-rules').bytes),\n    \"Total Time Savings (ms)\": getSav('render-blocking-insight').ms,\n\n    // PAGE COMPOSITION\n    \"Total Requests\": getRes('total').count,\n    \"Total Page Size (Bytes)\": getRes('total').size,\n    \"HTML Size (Bytes)\": getRes('document').size,\n    \"HTML Count\": getRes('document').count,\n    \"Image Size (Bytes)\": getRes('image').size,\n    \"Image Count\": getRes('image').count,\n    \"CSS Size (Bytes)\": getRes('stylesheet').size,\n    \"CSS Count\": getRes('stylesheet').count,\n    \"JavaScript Size (Bytes)\": getRes('script').size,\n    \"JavaScript Count\": getRes('script').count,\n    \"Font Size (Bytes)\": getRes('font').size,\n    \"Font Count\": getRes('font').count,\n    \"Third Party Size (Bytes)\": getRes('third-party').size,\n    \"Third Party Count\": getRes('third-party').count,\n\n    // GRANULAR SAVINGS\n    \"Minify CSS Savings (ms)\": getSav('unminified-css').ms,\n    \"Minify CSS Savings (Bytes)\": getSav('unminified-css').bytes,\n    \"Minify JavaScript Savings (ms)\": getSav('unminified-javascript').ms,\n    \"Minify JavaScript Savings (Bytes)\": getSav('unminified-javascript').bytes,\n    \"Reduce Unused CSS Savings (ms)\": getSav('unused-css-rules').ms,\n    \"Reduce Unused CSS Savings (Bytes)\": getSav('unused-css-rules').bytes,\n    \"Reduce Unused JavaScript Savings (ms)\": getSav('unused-javascript').ms,\n    \"Reduce Unused JavaScript Savings (Bytes)\": getSav('unused-javascript').bytes,\n\n    // ADVANCED DIAGNOSTICS & INSIGHT MAPPINGS\n    \"JavaScript Execution Time (ms)\": audits['bootup-time'] ? audits['bootup-time'].numericValue : 0,\n    \"JavaScript Execution Time Category\": getCat(audits['bootup-time'] ? audits['bootup-time'].score : 0),\n    \"Minimize Main-Thread Work (ms)\": audits['mainthread-work-breakdown'] ? audits['mainthread-work-breakdown'].numericValue : 0,\n    \"Minimize Main-Thread Work Category\": getCat(audits['mainthread-work-breakdown'] ? audits['mainthread-work-breakdown'].score : 0),\n    \"Network Payload Size (Bytes)\": audits['total-byte-weight'] ? audits['total-byte-weight'].numericValue : 0,\n    \"Accessibility Score\": Math.round((categories.accessibility ? categories.accessibility.score : 0) * 100),\n    \"Server Responds Quickly\": audits['server-response-time'] ? audits['server-response-time'].displayValue : \"N/A\",\n    \n    // Corrected logic for nested v13 Insight items\n    \"Applies Text Compression\": (docLatency && docLatency.details && docLatency.details.items && docLatency.details.items.usesCompression && docLatency.details.items.usesCompression.value) ? \"Yes\" : \"No\",\n    \"LCP Request Discovery\": (lcpDiscovery && lcpDiscovery.details && lcpDiscovery.details.items && lcpDiscovery.details.items.requestDiscoverable && lcpDiscovery.details.items.requestDiscoverable.value) ? \"Optimized\" : \"Action Required\",\n    \"LCP Breakdown\": (audits['lcp-breakdown-insight'] && audits['lcp-breakdown-insight'].details && audits['lcp-breakdown-insight'].details.items ? audits['lcp-breakdown-insight'].details.items.length : 0) + \" segments\",\n    \n    \"Render Blocking Requests Savings (ms)\": getSav('render-blocking-insight').ms,\n    \"Preconnect Candidates Savings (ms)\": getSav('uses-rel-preconnect').ms,\n    \"Maximum Critical Path Latency (ms)\": (netTree && netTree.details && netTree.details.items && netTree.details.items.value && netTree.details.items.value.longestChain) ? netTree.details.items.value.longestChain.duration : 0,\n    \"Use Efficient Cache Lifetimes Savings (Bytes)\": getSav('cache-insight').bytes,\n    \"Layout Shift Culprits\": (audits['cls-culprits-insight'] && audits['cls-culprits-insight'].details && audits['cls-culprits-insight'].details.items) ? audits['cls-culprits-insight'].details.items.length : 0,\n    \"DOM Size\": audits['dom-size-insight'] ? audits['dom-size-insight'].numericValue : 0,\n    \"Improve Image Delivery Savings (Bytes)\": getSav('image-delivery-insight').bytes,\n    \"Forced Reflow Savings (ms)\": getSav('forced-reflow-insight').ms,\n    \"Legacy JavaScript Savings (Bytes)\": getSav('legacy-javascript-insight').bytes,\n    \"Duplicated JavaScript (Bytes)\": getSav('duplicated-javascript-insight').bytes,\n    \"Font Display Savings (ms)\": getSav('font-display-insight').ms,\n    \"3rd Parties\": (thirdParties && thirdParties.details && thirdParties.details.items ? thirdParties.details.items.length : 0) + \" resources found\"\n  });\n}\n\nreturn results.map(r => ({ json: r }));"
      },
      "id": "2427609a-b55d-418c-a488-cd7ef2589797",
      "name": "Extract PageSpeed Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5104,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://uguu.se/upload",
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "files[]",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {}
      },
      "id": "93289d38-4a0e-4db0-9609-2b06daf39c65",
      "name": "Upload to Uguu",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        4576,
        64
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "message": "=The following URLs:\n`{{ $json.valid_urls }}`\n\nReturned the status code: **{{ $json.status }}**\n\nThese URLs will not be analyzed in this run.",
        "waitUserReply": false,
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.chat",
      "typeVersion": 1,
      "position": [
        3520,
        176
      ],
      "id": "af84a32d-c664-40d9-9975-30d00e6fb16c",
      "name": "Respond to Chat"
    },
    {
      "parameters": {
        "options": {
          "responseMode": "responseNodes"
        }
      },
      "type": "@n8n/n8n-nodes-langchain.chatTrigger",
      "typeVersion": 1.4,
      "position": [
        352,
        192
      ],
      "id": "806efc91-674a-4f1e-9c38-e73b13b88c59",
      "name": "When chat message received",
      "webhookId": "591e3178-ddb5-4ba7-9824-914ada10ce6d"
    },
    {
      "parameters": {
        "amount": 10,
        "unit": "seconds"
      },
      "id": "6d657ee1-b1e3-4b33-95d5-d589e3cf1f88",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        4768,
        208
      ],
      "webhookId": "c013fad0-f5fb-47d9-a4ab-3b6beea1e9db"
    },
    {
      "parameters": {
        "options": {}
      },
      "type": "n8n-nodes-base.convertToFile",
      "typeVersion": 1.1,
      "position": [
        4256,
        64
      ],
      "id": "c9f456e6-82e0-475d-a08c-7ded3dcd7b29",
      "name": "Convert to File"
    },
    {
      "parameters": {
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "valid_urls"
            },
            {
              "fieldToAggregate": "status"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.aggregate",
      "typeVersion": 1,
      "position": [
        3184,
        176
      ],
      "id": "cc6845c1-c677-402b-b3e4-04ef6bd6bd19",
      "name": "Aggregate"
    },
    {
      "parameters": {
        "jsCode": "const url = $json.files?.[0]?.url;\nreturn [\n  {\n    json: {\n      message: `<${url}>`\n    }\n  }\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4912,
        64
      ],
      "id": "6df5d5f8-040c-49ca-9466-ca5bbecbc5ef",
      "name": "Extract Link"
    },
    {
      "parameters": {
        "message": "=The process is complete. Download your scores here:\n{{ $json.message }}\n",
        "waitUserReply": false,
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.chat",
      "typeVersion": 1,
      "position": [
        5264,
        64
      ],
      "id": "eb7e6d22-090a-4c1b-8474-aaeb0a59b897",
      "name": "Final Chat",
      "executeOnce": true
    },
    {
      "parameters": {
        "content": "### ๐Ÿ’ฌ When chat message received\n\nActs as the entry point for the bulk PageSpeed workflow. It listens for the user to submit a text message containing one or more URLs to be tested.\n\n**Configuration:**\n* **Node Type:** Chat Trigger\n* **Setup:** Listens for the `chatInput` string from the user.",
        "height": 264,
        "width": 320,
        "color": 7
      },
      "id": "8fb844d3-912e-4f69-871a-88d7b3c22361",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        240,
        352
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿงน Parse & Normalise URLs\n\nIntercepts the raw chat input to extract, clean, and standardize the provided URLs. This ensures the workflow only attempts to process correctly formatted links and prevents errors downstream.\n\n**Configuration:**\n* **Node Type:** Code / Edit Fields\n* **Setup:** Parses the input text into a structured array of URLs and checks for basic formatting.",
        "height": 284,
        "width": 320,
        "color": 7
      },
      "id": "1c592ccc-baf3-41e6-b724-52d54e1742d7",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        576,
        336
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ”€ Early Exit Check\n\nEvaluates the output from the parser to see if any valid URLs were actually found. If no valid URLs exist ('True'), it routes to an error message. If valid URLs exist ('False'), it continues processing.\n\n**Configuration:**\n* **Node Type:** IF\n* **Setup:** Checks the validation condition to determine the routing path.",
        "height": 280,
        "width": 320,
        "color": 7
      },
      "id": "5268e6cd-71c9-4b5c-b418-acdd78289133",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        912,
        336
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ›‘ Respond - No Valid URLs\n\nSends an immediate error message back to the chat if the user's input contained absolutely no valid URLs, halting the workflow safely so the user can correct their input and try again.\n\n**Configuration:**\n* **Node Type:** Chat Response\n* **Setup:** Outputs a friendly error prompt asking the user for properly formatted links.",
        "height": 300,
        "width": 320,
        "color": 7
      },
      "id": "0f7ab450-1613-4410-8183-d664999564c4",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1248,
        320
      ]
    },
    {
      "parameters": {
        "content": "### โœ‚๏ธ Split Valid URLs\n\nFlattens the array of validated URLs from the parser into individual items. This step ensures that each URL can be checked individually for uptime in the next node.\n\n**Configuration:**\n* **Node Type:** Item Lists / Split Out\n* **Setup:** Targets the specific array of valid URLs to separate them into distinct iterations.",
        "height": 264,
        "width": 320,
        "color": 7
      },
      "id": "daf81ab7-da44-453f-88ac-2ccbf8f802e4",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1616,
        352
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ“ก Reachability Check\n\nExecutes a lightweight HTTP request to each valid URL to verify that the website is currently online and accessible. This prevents wasting PageSpeed API quota on dead links.\n\n**Configuration:**\n* **Node Type:** HTTP Request\n* **Setup:** Pings the individual URLs and gracefully catches any errors or timeouts.",
        "height": 300,
        "width": 320,
        "color": 7
      },
      "id": "8c088194-6c0b-45ae-a626-b67cfd291f9f",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2128,
        352
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ›ก๏ธ Filter Reachable\n\nEvaluates the responses from the reachability ping, cleanly separating the URLs that successfully loaded from those that returned errors, timeouts, or 404s.\n\n**Configuration:**\n* **Node Type:** Code / Edit Fields\n* **Setup:** Maps the HTTP status codes to flag which URLs are safe to pass to the Google API.",
        "height": 280,
        "width": 320,
        "color": 7
      },
      "id": "79348133-58b0-4d5f-a2f2-f0ec5e43875b",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2496,
        352
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ”€ Second Early Exit\n\nA final safety check before the heavy PageSpeed processing begins. It evaluates if *any* URLs actually passed the reachability test. If all failed, it routes to an error; otherwise, it proceeds to the main loop.\n\n**Configuration:**\n* **Node Type:** IF\n* **Setup:** Checks the boolean output from the filter node to determine the workflow route.",
        "height": 304,
        "width": 320,
        "color": 7
      },
      "id": "9611ec3d-a563-4d62-8183-d60e5b7c4ed7",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2832,
        336
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ“ฆ Aggregate\n\nCollects all the URLs that failed the reachability ping (e.g., returned 404s or timed out) and bundles them into a single list to be reported back to the user.\n\n**Configuration:**\n* **Node Type:** Aggregate\n* **Setup:** Groups the unreachable URL items back into a single array.",
        "height": 248,
        "width": 320,
        "color": 7
      },
      "id": "49c4ed4c-f459-4d45-af8e-fe02ba665755",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3072,
        -96
      ]
    },
    {
      "parameters": {
        "content": "### โš ๏ธ Respond to Chat\n\nSends an alert to the chat interface listing exactly which URLs were offline or unreachable. This ensures the user knows which sites were skipped while the rest continue processing.\n\n**Configuration:**\n* **Node Type:** Chat Response\n* **Setup:** Outputs a warning message referencing the aggregated list of failed URLs.",
        "height": 284,
        "width": 320,
        "color": 7
      },
      "id": "914ba747-5140-4c83-aa6b-9ff53dbf17ba",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3408,
        -128
      ]
    },
    {
      "parameters": {
        "content": "### โœ‚๏ธ Split Reachable URLs\n\nIsolates the clean, online URLs into distinct items. This prepares the data to be fed sequentially into the PageSpeed API loop.\n\n**Configuration:**\n* **Node Type:** Item Lists / Split Out\n* **Setup:** Targets the array of reachable URLs to process them one by one.",
        "height": 264,
        "width": 320,
        "color": 7
      },
      "id": "25751df4-c644-4371-a5ef-8e2b6c0761ef",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3568,
        368
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ” Loop\n\nIterates through the verified online URLs individually. This prevents overloading the Google PageSpeed API and avoids rate-limit errors by processing one site at a time.\n\n**Configuration:**\n* **Node Type:** Loop\n* **Setup:** Iterates over the items provided by the Split node.",
        "height": 264,
        "width": 320,
        "color": 7
      },
      "id": "5cac79e2-45d1-4858-ba27-6f80ae5a2145",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3984,
        400
      ]
    },
    {
      "parameters": {
        "content": "### ๐ŸŒ PageSpeed API\n\nExecutes a GET request to the Google PageSpeed Insights API for each URL. This is the core engine of the workflow, fetching performance, SEO, accessibility, and best practice metrics.\n\n**Configuration:**\n* **Node Type:** HTTP Request\n* **Setup:** Configured to ping the Google API endpoint with necessary parameters (e.g., `strategy=mobile`).",
        "height": 300,
        "width": 320,
        "color": 7
      },
      "id": "8ee948b4-562f-424e-b7c0-ab906ebe9a5a",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4320,
        384
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ“Š Extract PageSpeed Data\n\nSifts through the massive JSON payload returned by Google to extract only the specific scores you care about (e.g., Performance, SEO, Accessibility, Best Practices) and pairs them cleanly with the target URL.\n\n**Configuration:**\n* **Node Type:** Code / Edit Fields\n* **Setup:** Maps the deeply nested metric data into simple, flat fields for the final spreadsheet.",
        "height": 316,
        "width": 320,
        "color": 7
      },
      "id": "be6e21b3-774a-43d9-95cf-c81bf9e49265",
      "name": "Sticky Note13",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4992,
        384
      ]
    },
    {
      "parameters": {
        "content": "### โณ Wait (Rate Limit Retry)\n\nActs as a safety valve. If the Google API returns an error (such as a 429 Too Many Requests), this node pauses execution briefly before retrying, preventing the workflow from crashing under heavy loads.\n\n**Configuration:**\n* **Node Type:** Wait\n* **Setup:** Delays the execution for a set duration (e.g., 5 seconds) before looping back to try the API request again.",
        "height": 316,
        "width": 320,
        "color": 7
      },
      "id": "1b868daa-2072-4afa-9f6d-c6a63aceb172",
      "name": "Sticky Note14",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4656,
        384
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ“„ Convert to File\n\nTakes all the cleanly formatted PageSpeed data collected during the loop and compiles it into a single binary CSV spreadsheet, ready for export.\n\n**Configuration:**\n* **Node Type:** Convert to File / Spreadsheet File\n* **Setup:** Converts the aggregated JSON performance data into a standard CSV format.",
        "height": 296,
        "width": 320,
        "color": 7
      },
      "id": "ee8a3d90-d138-471b-a8e8-f38e26e3fe76",
      "name": "Sticky Note15",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4128,
        -256
      ]
    },
    {
      "parameters": {
        "content": "### โ˜๏ธ Upload to Uguu\n\nTransmits the newly generated binary CSV file to an external, temporary file-hosting service. This generates a shareable public link, bypassing chat window attachment limitations.\n\n**Configuration:**\n* **Node Type:** HTTP Request\n* **Setup:** Executes a POST request (`multipart/form-data`) containing the binary CSV file.",
        "height": 284,
        "width": 320,
        "color": 7
      },
      "id": "c60702eb-1e6a-4b25-bf3d-c9a6a71ecf41",
      "name": "Sticky Note16",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4464,
        -240
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ”— Extract Link\n\nParses the response from the file-hosting service to specifically isolate the direct public download URL for your new CSV file.\n\n**Configuration:**\n* **Node Type:** Code / Edit Fields\n* **Setup:** Targets the specific property in the host's response payload that contains the generated web link.",
        "height": 264,
        "width": 320,
        "color": 7
      },
      "id": "579b1102-b5b1-422b-bf9f-6c42e0b20600",
      "name": "Sticky Note17",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4800,
        -224
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿš€ Final Chat\n\nDelivers the final public download link back to the user in the chat window, successfully completing the bulk PageSpeed analysis workflow.\n\n**Configuration:**\n* **Node Type:** Chat Response\n* **Setup:** Outputs a friendly success message containing the final, clickable URL for the user to grab their performance data CSV.",
        "height": 300,
        "width": 320,
        "color": 7
      },
      "id": "61e801db-ff67-4963-ae56-9bc1c57f524b",
      "name": "Sticky Note18",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        5168,
        -256
      ]
    },
    {
      "parameters": {
        "content": "### ๐Ÿ“– README: Bulk PageSpeed Performance Tool\n\n**The Problem:**\nTesting multiple URLs through Google PageSpeed Insights manually is slow, and compiling the scores into a spreadsheet is tedious.\n\n**The Solution:**\nThis workflow automates bulk performance testing. Simply paste a list of URLs into the chat. The workflow will validate the links, check if they are online, run them sequentially through the PageSpeed API (to avoid rate limits), and compile the metrics into a single, downloadable CSV file.\n\n**How to Use:**\n1. Click **Open chat** at the bottom of the canvas.\n2. Paste your list of target URLs.\n3. Wait for the analysis to complete (larger lists take longer due to API rate limiting).\n4. Click the final generated link to download your compiled CSV report.",
        "height": 376,
        "width": 626,
        "color": 6
      },
      "id": "4b8b934f-41d5-4c03-884f-25754b86d828",
      "name": "Sticky Note19",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -608,
        -64
      ]
    },
    {
      "parameters": {
        "content": "## 1๏ธโƒฃ Phase 1: Input & Validation\n**Purpose:** Handles the initial chat trigger, parses the raw input text to extract URLs, and ensures valid links were provided before continuing.",
        "height": 120,
        "width": 1762,
        "color": 5
      },
      "id": "f75b88aa-cd3f-4355-a555-f4a8a41f51a2",
      "name": "Sticky Note21",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        208,
        0
      ]
    },
    {
      "parameters": {
        "content": "## 2๏ธโƒฃ Phase 2: Pre-Flight Reachability\n**Purpose:** Pings each validated URL to verify it is actively online. Skips dead links to save time and API quota, alerting the user to failures.",
        "height": 120,
        "width": 1854,
        "color": 3
      },
      "id": "7c6ec796-4533-4e7f-ab18-3a7dbdc6e689",
      "name": "Sticky Note22",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2048,
        -272
      ]
    },
    {
      "parameters": {
        "content": "## 3๏ธโƒฃ Phase 3: PageSpeed API Loop\n**Purpose:** Iterates safely through online URLs, fetches Lighthouse/Core Web Vitals metrics via the Google API, and formats the nested JSON data into flat spreadsheet rows.",
        "height": 120,
        "width": 1422,
        "color": 2
      },
      "id": "f0a270bb-baf0-453b-baca-d7546891996f",
      "name": "Sticky Note23",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3936,
        768
      ]
    },
    {
      "parameters": {
        "content": "## 4๏ธโƒฃ Phase 4: Output & Delivery\n**Purpose:** Compiles the formatted API data into a single CSV, uploads it to a temporary file host, and delivers the download link via chat.",
        "height": 120,
        "width": 1470,
        "color": 6
      },
      "id": "bbf294e4-6d34-45c2-9ef5-4b8c0a6ef4d0",
      "name": "Sticky Note24",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4080,
        -416
      ]
    },
    {
      "parameters": {
        "content": "### โš ๏ธ Dependencies & Limitations\n\n**Dependencies:**\n* **Google PageSpeed API Credentials:** This workflow is configured to use n8n's credential manager for the Google API. To ensure successful runs and process large batches, you need to create and connect your own free Google API Key. If you attempt to run this without connecting a key, you will quickly hit strict free-tier rate limits, resulting in failed attempts and errors.\n* **External File Hosting:** Uses `uguu.se` to host the output CSV and bypass chat attachment limits. \n\n**Limitations:**\n* **Execution Time:** The Google API takes a few seconds per URL. Processing a list of 50+ URLs will take several minutes. Do not close the chat window while it runs.\n* **File Expiration:** Files uploaded to temporary hosts like `uguu.se` usually expire within 24-48 hours.\n* **Rate Limits:** The built-in Wait node helps manage request pacing, but massive lists processed without a proper API key attached will encounter `429 Too Many Requests` errors from Google.",
        "height": 412,
        "width": 642,
        "color": 4
      },
      "id": "4f1e4d1b-9fd4-4a67-b76b-e079290f8aeb",
      "name": "Sticky Note20",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -608,
        352
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "Parse & Normalise URLs": {
      "main": [
        [
          {
            "node": "Early Exit Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Early Exit Check": {
      "main": [
        [
          {
            "node": "Respond - No Valid URLs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split Valid URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Valid URLs": {
      "main": [
        [
          {
            "node": "Reachability Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reachability Check": {
      "main": [
        [
          {
            "node": "Filter Reachable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Reachable": {
      "main": [
        [
          {
            "node": "Second Early Exit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Second Early Exit": {
      "main": [
        [
          {
            "node": "Aggregate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Split Reachable URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Reachable URLs": {
      "main": [
        [
          {
            "node": "Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop": {
      "main": [
        [
          {
            "node": "Convert to File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "PageSpeed API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PageSpeed API": {
      "main": [
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract PageSpeed Data": {
      "main": [
        [
          {
            "node": "Loop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Uguu": {
      "main": [
        [
          {
            "node": "Extract Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When chat message received": {
      "main": [
        [
          {
            "node": "Parse & Normalise URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Extract PageSpeed Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert to File": {
      "main": [
        [
          {
            "node": "Upload to Uguu",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate": {
      "main": [
        [
          {
            "node": "Respond to Chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Link": {
      "main": [
        [
          {
            "node": "Final Chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond to Chat": {
      "main": [
        []
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  },
  "versionId": "7deb5a58-ff59-4e79-9763-c1c098537b13",
  "meta": {
    "instanceId": "edf1ced83e9ab9272c6f8c5577054e33710c688c25fbcb912c88d83471eb73e3"
  },
  "id": "PZnYpEnFk9A1_2qN6tMDH",
  "tags": []
}

Dependencies & Limitations of this SEO Process Automation

Because this is a simple workflow, you should know the limitations of this process. Most limitations depend on your system memory limits, one depends on the Pagespeed API response from Google and one depends on the temporary file hosting service.

Memory limits โ€“ Processing dozens of URLs in the worflow with the API might return massive amount of data, which might cause memory timeout errors, depending on your server resources or resource allocation to your system.

Pagespeed API response โ€“ The API has a usage quota or a limit. While the limits are not explicitely specified, it is believed to be 25,000 calls per day and 240 requests per minute.

Temporary file hosting โ€“ Because the temporary file hosting is an external service, sometimes it might be down or blocked in your enterprise setting. You can use your local machine to save files, or use your company-approved service to store the files for a longer duration to bypass this limitation.

Your thoughts?

Let me know if you found this helpful. If you need a thing or two changed here, happy to do that โ€“ connect with me on LinkedIn or via email from the footer/about page and let me know what you need.

Siddharth
Siddharth

Siddharth is an SEO specialist who began his digital marketing journey by experimenting on his own blog, using it as a playground to test strategies and learn from real results. He has consulted for various startups, small and medium businesses, and enterprise firms, helping them grow through data-driven SEO. He holds an MBA in Marketing and Finance from the Delhi School of Business. When he is not working on projects, he prefers to spend his time meeting friends offline.

Articles: 25

Leave a Reply

Your email address will not be published. Required fields are marked *