This workflow corresponds to n8n.io template #15548 — we link there as the canonical source.
This workflow follows the Chat → Chat Trigger 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 →
{
"id": "PZnYpEnFk9A1_2qN6tMDH",
"name": "Bulk Google PageSpeed Insights to CSV",
"tags": [],
"nodes": [
{
"id": "23b42988-ba4f-4acb-9b45-e3c30d971749",
"name": "Parse & Normalise URLs",
"type": "n8n-nodes-base.code",
"position": [
672,
192
],
"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 = \"\u26a0\ufe0f 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}];"
},
"typeVersion": 2
},
{
"id": "14ebe575-a8f1-4e01-85b9-a38534818ba5",
"name": "Early Exit Check",
"type": "n8n-nodes-base.if",
"position": [
1024,
192
],
"parameters": {
"conditions": {
"number": [
{
"value1": "={{ $json.total_valid }}",
"operation": "equal"
}
]
}
},
"typeVersion": 1
},
{
"id": "899e3008-be7f-4bde-9d7e-758a80261f59",
"name": "Respond - No Valid URLs",
"type": "n8n-nodes-base.code",
"position": [
1360,
176
],
"parameters": {
"jsCode": "return [{ json: { output: \"\u274c No valid URLs were found in your input. Please enter at least one properly formatted URL (e.g. https://example.com) and try again.\" } }];"
},
"typeVersion": 2
},
{
"id": "18bc2888-b2b0-4117-aef7-27b6cc5d4c84",
"name": "Split Valid URLs",
"type": "n8n-nodes-base.itemLists",
"position": [
1712,
208
],
"parameters": {
"options": {},
"fieldToSplitOut": "valid_urls"
},
"typeVersion": 3
},
{
"id": "77358009-b3b8-414e-99a4-6c96adc2c68c",
"name": "Reachability Check",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueErrorOutput",
"position": [
2208,
208
],
"parameters": {
"url": "={{ $json.valid_urls }}",
"method": "HEAD",
"options": {
"response": {
"response": {
"neverError": true,
"fullResponse": true
}
}
}
},
"typeVersion": 4,
"continueOnFail": true,
"alwaysOutputData": true
},
{
"id": "00c80806-8d5d-4c69-a2ee-3c475738a860",
"name": "Filter Reachable",
"type": "n8n-nodes-base.code",
"position": [
2592,
192
],
"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});"
},
"typeVersion": 2
},
{
"id": "7ba453a7-d60f-49ed-a30c-fa4e26ce318e",
"name": "Second Early Exit",
"type": "n8n-nodes-base.if",
"position": [
2928,
192
],
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.status }}",
"value2": "={{200}}",
"operation": "notEqual"
}
]
}
},
"typeVersion": 1
},
{
"id": "006d5828-2f75-4841-9cb7-3ded070c854d",
"name": "Split Reachable URLs",
"type": "n8n-nodes-base.itemLists",
"position": [
3712,
208
],
"parameters": {
"options": {},
"fieldToSplitOut": "valid_urls"
},
"typeVersion": 3
},
{
"id": "44703885-1d21-45c6-8e55-438e615d83d1",
"name": "Loop",
"type": "n8n-nodes-base.splitInBatches",
"position": [
4064,
208
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "5b1325c3-3e47-485a-a299-e65a9def2452",
"name": "PageSpeed API",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueErrorOutput",
"position": [
4432,
224
],
"parameters": {
"url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?&strategy=mobile&category=performance&category=seo&category=best-practices&category=accessibility",
"options": {
"timeout": 90000,
"batching": {
"batch": {
"batchSize": 1,
"batchInterval": 3000
}
},
"queryParameterArrays": "repeat"
},
"sendQuery": true,
"authentication": "genericCredentialType",
"genericAuthType": "httpQueryAuth",
"queryParameters": {
"parameters": [
{
"name": "url",
"value": "={{ $json.valid_urls }}"
}
]
}
},
"credentials": {
"httpQueryAuth": {
"name": "<your credential>"
}
},
"typeVersion": 4,
"continueOnFail": true
},
{
"id": "2427609a-b55d-418c-a488-cd7ef2589797",
"name": "Extract PageSpeed Data",
"type": "n8n-nodes-base.code",
"position": [
5104,
208
],
"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 }));"
},
"typeVersion": 2
},
{
"id": "93289d38-4a0e-4db0-9609-2b06daf39c65",
"name": "Upload to Uguu",
"type": "n8n-nodes-base.httpRequest",
"position": [
4576,
64
],
"parameters": {
"url": "https://uguu.se/upload",
"method": "POST",
"options": {},
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"name": "files[]",
"parameterType": "formBinaryData",
"inputDataFieldName": "data"
}
]
}
},
"typeVersion": 4,
"continueOnFail": true
},
{
"id": "af84a32d-c664-40d9-9975-30d00e6fb16c",
"name": "Respond to Chat",
"type": "@n8n/n8n-nodes-langchain.chat",
"position": [
3520,
176
],
"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.",
"options": {},
"waitUserReply": false
},
"typeVersion": 1
},
{
"id": "806efc91-674a-4f1e-9c38-e73b13b88c59",
"name": "When chat message received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"position": [
352,
192
],
"parameters": {
"options": {
"responseMode": "responseNodes"
}
},
"typeVersion": 1.4
},
{
"id": "6d657ee1-b1e3-4b33-95d5-d589e3cf1f88",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"position": [
4768,
208
],
"parameters": {
"unit": "seconds",
"amount": 10
},
"typeVersion": 1
},
{
"id": "c9f456e6-82e0-475d-a08c-7ded3dcd7b29",
"name": "Convert to File",
"type": "n8n-nodes-base.convertToFile",
"position": [
4256,
64
],
"parameters": {
"options": {}
},
"typeVersion": 1.1
},
{
"id": "cc6845c1-c677-402b-b3e4-04ef6bd6bd19",
"name": "Aggregate",
"type": "n8n-nodes-base.aggregate",
"position": [
3184,
176
],
"parameters": {
"options": {},
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "valid_urls"
},
{
"fieldToAggregate": "status"
}
]
}
},
"typeVersion": 1
},
{
"id": "6df5d5f8-040c-49ca-9466-ca5bbecbc5ef",
"name": "Extract Link",
"type": "n8n-nodes-base.code",
"position": [
4912,
64
],
"parameters": {
"jsCode": "const url = $json.files?.[0]?.url;\nreturn [\n {\n json: {\n message: `<${url}>`\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "eb7e6d22-090a-4c1b-8474-aaeb0a59b897",
"name": "Final Chat",
"type": "@n8n/n8n-nodes-langchain.chat",
"position": [
5264,
64
],
"parameters": {
"message": "=The process is complete. Download your scores here:\n{{ $json.message }}\n",
"options": {},
"waitUserReply": false
},
"executeOnce": true,
"typeVersion": 1
},
{
"id": "8fb844d3-912e-4f69-871a-88d7b3c22361",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
240,
352
],
"parameters": {
"color": 7,
"width": 320,
"height": 264,
"content": "### \ud83d\udcac 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."
},
"typeVersion": 1
},
{
"id": "1c592ccc-baf3-41e6-b724-52d54e1742d7",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
576,
336
],
"parameters": {
"color": 7,
"width": 320,
"height": 284,
"content": "### \ud83e\uddf9 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."
},
"typeVersion": 1
},
{
"id": "5268e6cd-71c9-4b5c-b418-acdd78289133",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
912,
336
],
"parameters": {
"color": 7,
"width": 320,
"height": 280,
"content": "### \ud83d\udd00 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."
},
"typeVersion": 1
},
{
"id": "0f7ab450-1613-4410-8183-d664999564c4",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1248,
320
],
"parameters": {
"color": 7,
"width": 320,
"height": 300,
"content": "### \ud83d\uded1 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."
},
"typeVersion": 1
},
{
"id": "daf81ab7-da44-453f-88ac-2ccbf8f802e4",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1616,
352
],
"parameters": {
"color": 7,
"width": 320,
"height": 264,
"content": "### \u2702\ufe0f 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."
},
"typeVersion": 1
},
{
"id": "8c088194-6c0b-45ae-a626-b67cfd291f9f",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
2128,
352
],
"parameters": {
"color": 7,
"width": 320,
"height": 300,
"content": "### \ud83d\udce1 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."
},
"typeVersion": 1
},
{
"id": "79348133-58b0-4d5f-a2f2-f0ec5e43875b",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
2496,
352
],
"parameters": {
"color": 7,
"width": 320,
"height": 280,
"content": "### \ud83d\udee1\ufe0f 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."
},
"typeVersion": 1
},
{
"id": "9611ec3d-a563-4d62-8183-d60e5b7c4ed7",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
2832,
336
],
"parameters": {
"color": 7,
"width": 320,
"height": 304,
"content": "### \ud83d\udd00 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."
},
"typeVersion": 1
},
{
"id": "49c4ed4c-f459-4d45-af8e-fe02ba665755",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
3072,
-96
],
"parameters": {
"color": 7,
"width": 320,
"height": 248,
"content": "### \ud83d\udce6 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."
},
"typeVersion": 1
},
{
"id": "914ba747-5140-4c83-aa6b-9ff53dbf17ba",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
3408,
-128
],
"parameters": {
"color": 7,
"width": 320,
"height": 284,
"content": "### \u26a0\ufe0f 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."
},
"typeVersion": 1
},
{
"id": "25751df4-c644-4371-a5ef-8e2b6c0761ef",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"position": [
3568,
368
],
"parameters": {
"color": 7,
"width": 320,
"height": 264,
"content": "### \u2702\ufe0f 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."
},
"typeVersion": 1
},
{
"id": "5cac79e2-45d1-4858-ba27-6f80ae5a2145",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"position": [
3984,
400
],
"parameters": {
"color": 7,
"width": 320,
"height": 264,
"content": "### \ud83d\udd01 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."
},
"typeVersion": 1
},
{
"id": "8ee948b4-562f-424e-b7c0-ab906ebe9a5a",
"name": "Sticky Note12",
"type": "n8n-nodes-base.stickyNote",
"position": [
4320,
384
],
"parameters": {
"color": 7,
"width": 320,
"height": 300,
"content": "### \ud83c\udf10 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`)."
},
"typeVersion": 1
},
{
"id": "be6e21b3-774a-43d9-95cf-c81bf9e49265",
"name": "Sticky Note13",
"type": "n8n-nodes-base.stickyNote",
"position": [
4992,
384
],
"parameters": {
"color": 7,
"width": 320,
"height": 316,
"content": "### \ud83d\udcca 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."
},
"typeVersion": 1
},
{
"id": "1b868daa-2072-4afa-9f6d-c6a63aceb172",
"name": "Sticky Note14",
"type": "n8n-nodes-base.stickyNote",
"position": [
4656,
384
],
"parameters": {
"color": 7,
"width": 320,
"height": 316,
"content": "### \u23f3 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."
},
"typeVersion": 1
},
{
"id": "ee8a3d90-d138-471b-a8e8-f38e26e3fe76",
"name": "Sticky Note15",
"type": "n8n-nodes-base.stickyNote",
"position": [
4128,
-256
],
"parameters": {
"color": 7,
"width": 320,
"height": 296,
"content": "### \ud83d\udcc4 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."
},
"typeVersion": 1
},
{
"id": "c60702eb-1e6a-4b25-bf3d-c9a6a71ecf41",
"name": "Sticky Note16",
"type": "n8n-nodes-base.stickyNote",
"position": [
4464,
-240
],
"parameters": {
"color": 7,
"width": 320,
"height": 284,
"content": "### \u2601\ufe0f 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."
},
"typeVersion": 1
},
{
"id": "579b1102-b5b1-422b-bf9f-6c42e0b20600",
"name": "Sticky Note17",
"type": "n8n-nodes-base.stickyNote",
"position": [
4800,
-224
],
"parameters": {
"color": 7,
"width": 320,
"height": 264,
"content": "### \ud83d\udd17 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."
},
"typeVersion": 1
},
{
"id": "61e801db-ff67-4963-ae56-9bc1c57f524b",
"name": "Sticky Note18",
"type": "n8n-nodes-base.stickyNote",
"position": [
5168,
-256
],
"parameters": {
"color": 7,
"width": 320,
"height": 300,
"content": "### \ud83d\ude80 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."
},
"typeVersion": 1
},
{
"id": "4b8b934f-41d5-4c03-884f-25754b86d828",
"name": "Sticky Note19",
"type": "n8n-nodes-base.stickyNote",
"position": [
-608,
-64
],
"parameters": {
"color": 6,
"width": 626,
"height": 376,
"content": "### \ud83d\udcd6 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."
},
"typeVersion": 1
},
{
"id": "f75b88aa-cd3f-4355-a555-f4a8a41f51a2",
"name": "Sticky Note21",
"type": "n8n-nodes-base.stickyNote",
"position": [
208,
0
],
"parameters": {
"color": 5,
"width": 1762,
"height": 120,
"content": "## 1\ufe0f\u20e3 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."
},
"typeVersion": 1
},
{
"id": "7c6ec796-4533-4e7f-ab18-3a7dbdc6e689",
"name": "Sticky Note22",
"type": "n8n-nodes-base.stickyNote",
"position": [
2048,
-272
],
"parameters": {
"color": 3,
"width": 1854,
"height": 120,
"content": "## 2\ufe0f\u20e3 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."
},
"typeVersion": 1
},
{
"id": "f0a270bb-baf0-453b-baca-d7546891996f",
"name": "Sticky Note23",
"type": "n8n-nodes-base.stickyNote",
"position": [
3936,
768
],
"parameters": {
"color": 2,
"width": 1422,
"height": 120,
"content": "## 3\ufe0f\u20e3 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."
},
"typeVersion": 1
},
{
"id": "bbf294e4-6d34-45c2-9ef5-4b8c0a6ef4d0",
"name": "Sticky Note24",
"type": "n8n-nodes-base.stickyNote",
"position": [
4080,
-416
],
"parameters": {
"color": 6,
"width": 1470,
"height": 120,
"content": "## 4\ufe0f\u20e3 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."
},
"typeVersion": 1
},
{
"id": "4f1e4d1b-9fd4-4a67-b76b-e079290f8aeb",
"name": "Sticky Note20",
"type": "n8n-nodes-base.stickyNote",
"position": [
-608,
352
],
"parameters": {
"color": 4,
"width": 642,
"height": 412,
"content": "### \u26a0\ufe0f 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."
},
"typeVersion": 1
}
],
"active": true,
"settings": {
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "7deb5a58-ff59-4e79-9763-c1c098537b13",
"connections": {
"Loop": {
"main": [
[
{
"node": "Convert to File",
"type": "main",
"index": 0
}
],
[
{
"node": "PageSpeed API",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Extract PageSpeed Data",
"type": "main",
"index": 0
}
]
]
},
"Aggregate": {
"main": [
[
{
"node": "Respond to Chat",
"type": "main",
"index": 0
}
]
]
},
"Extract Link": {
"main": [
[
{
"node": "Final Chat",
"type": "main",
"index": 0
}
]
]
},
"PageSpeed API": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Upload to Uguu": {
"main": [
[
{
"node": "Extract Link",
"type": "main",
"index": 0
}
]
]
},
"Convert to File": {
"main": [
[
{
"node": "Upload to Uguu",
"type": "main",
"index": 0
}
]
]
},
"Respond to Chat": {
"main": [
[]
]
},
"Early Exit Check": {
"main": [
[
{
"node": "Respond - No Valid URLs",
"type": "main",
"index": 0
}
],
[
{
"node": "Split Valid URLs",
"type": "main",
"index": 0
}
]
]
},
"Filter Reachable": {
"main": [
[
{
"node": "Second Early Exit",
"type": "main",
"index": 0
}
]
]
},
"Split Valid URLs": {
"main": [
[
{
"node": "Reachability Check",
"type": "main",
"index": 0
}
]
]
},
"Second Early Exit": {
"main": [
[
{
"node": "Aggregate",
"type": "main",
"index": 0
}
],
[
{
"node": "Split Reachable URLs",
"type": "main",
"index": 0
}
]
]
},
"Reachability Check": {
"main": [
[
{
"node": "Filter Reachable",
"type": "main",
"index": 0
}
]
]
},
"Split Reachable URLs": {
"main": [
[
{
"node": "Loop",
"type": "main",
"index": 0
}
]
]
},
"Extract PageSpeed Data": {
"main": [
[
{
"node": "Loop",
"type": "main",
"index": 0
}
]
]
},
"Parse & Normalise URLs": {
"main": [
[
{
"node": "Early Exit Check",
"type": "main",
"index": 0
}
]
]
},
"When chat message received": {
"main": [
[
{
"node": "Parse & Normalise URLs",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
httpQueryAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow accepts up to 10 URLs via chat input, executes the Google PageSpeed Insights API, and outputs a CSV file containing page performance metrics for SEO audits.
Source: https://n8n.io/workflows/15548/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Extracting URLs from multiple XML sitemaps manually is tedious, and combining them into a single usable file is time-consuming. This workflow solves this by acting as an automated bulk extractor. You
This workflow provides a streamlined, no-code solution to extract all nested URLs from any standard XML sitemap and instantly convert them into a structured CSV file. Built entirely within n8n's nativ
Aggregate Stickynote. Uses openAiAssistant, toolCalculator, memoryManager, limit. Chat trigger; 14 nodes.
Airtable. Uses chatTrigger, lmChatGoogleGemini, outputParserAutofixing, outputParserStructured. Chat trigger; 11 nodes.
Aggregate Http. Uses lmChatOpenAi, agent, stickyNote, memoryBufferWindow. Chat trigger; 41 nodes.