n8nCreativeWritingAI score · 82/100AI-assisted edit · no obvious prompt-injection riskn8n.io

Bulk Google PageSpeed Insights to CSV

Feed multiple URLs via chat in n8n, call the PageSpeed API, and get a downloadable CSV report

Some experienceStepsWorkflowPrompt
Who
SEO specialists, frontend developers, or ops who need batch Core Web Vitals checks
You get
Process up to 10 URLs per run, generate a CSV with LCP, FCP, CLS, Speed Index, and more, delivered via a temporary download link
You need
n8n · google-pagespeed-insights

Constraints

Prerequisites: an n8n instance (self-hosted or cloud) and a Google PageSpeed Insights API Key configured as httpQueryAuth credentials. Input is a space- or comma-separated URL list via chat. Output is a temporary CSV download link hosted on uguu.se. Limits: max 10 URLs per batch; API timeout 90s; temporary files expire in 24–48 hours.

What they did

Author siddharth published an n8n template that accepts a URL list via chat, then auto-validates links, checks reachability, calls the PageSpeed API in a loop, parses Lighthouse results, and delivers a CSV download link. The template contains 44 nodes (including 25 sticky notes) organized into four phases: input validation, pre-flight reachability, API loop, and output delivery.

How it went

Template self-reported effect (author_report): users paste a URL list and the workflow returns a downloadable CSV link. No specific processing time or success-rate figures provided.

Worth knowing

Max 10 URLs per batch; API timeout 90s; output files hosted on uguu.se expire in 24–48 hours. A Google API Key is mandatory to avoid 429 rate-limit errors. Processing 50+ URLs may take several minutes; do not close the chat window during execution. This site has not tested the workflow.

n8n workflow template

Structure checked · Not run-tested

Use “Import from File” in n8n, or copy the JSON and paste it into the editor. Connect your own accounts, then try test data. The template is inactive by default.

Setup and test checklist

Credentials to connect

  • PageSpeed API node: connect httpQueryAuth credentials.

Configuration to replace

  • Source template is n8n public workflow #15548 (~44 nodes). Keep inactive after import; verify triggers, expressions and external API endpoints.
  • Sticky-note guidance is informational; replace placeholders with your own accounts, sheet schemas or channel IDs.

Test input and expected output

  • Manually run the main path once in a test workspace and confirm credentialed nodes authenticate without errors.
  • Drive one synthetic/redacted sample through and check output fields and downstream writes before enabling in production.

Template changes

  • Author credential bindings, webhookId and execution data are removed; the workflow is inactive. Nodes, connections and original prompts are preserved. Not run-tested in n8n.
  • No business-logic edits; verify keys, quotas and privacy compliance before use.

Template retrieved: 2026-10-24

Steps

  1. Parse & Normalise URLs

    After receiving chat input, a Code node splits raw text by spaces or commas, filters valid URLs with a regex, auto-prefixes https://, deduplicates, and keeps at most 10 URLs. Invalid entries are recorded in skipped_input.

    Execution prompt · Source text。Code node 'Parse & Normalise URLs' jsCode field

    const rawInput = $input.first().json.chatInput || "";
    const rawUrls = rawInput.split(/[\s,]+/).filter(Boolean);
    
    let validUrls = [];
    let skipped = [];
    const urlRegex = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/i;
    
    for (let url of rawUrls) {
      if (!urlRegex.test(url)) {
        skipped.push(url);
        continue;
      }
      let clean = url.toLowerCase();
      if (!clean.startsWith('http://') && !clean.startsWith('https://')) {
        clean = 'https://' + clean;
      }
      if (!validUrls.includes(clean)) {
        validUrls.push(clean);
      }
    }
    
    let warning = "";
    if (validUrls.length > 10) {
      warning = "⚠️ Only the first 10 URLs will be processed. Remaining URLs have been skipped.";
      validUrls = validUrls.slice(0, 10);
    }
    
    return [{
      json: {
        valid_urls: validUrls,
        skipped_input: skipped,
        warning: warning,
        total_valid: validUrls.length
      }
    }];
  2. Validate and Branch

    Check the parsed valid_urls count. If total_valid equals 0, return an error via the Chat Response node asking for properly formatted URLs; otherwise split valid_urls into individual items and continue.

    Execution prompt · Source text。Code node 'Respond - No Valid URLs' jsCode field

    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." } }];
  3. Reachability Pre-check and Filter

    Run an HTTP HEAD request against each valid URL to verify it is online. URLs returning non-200 status codes are aggregated and reported to the user via chat; URLs with 200 are split into individual items and fed into the PageSpeed API loop. If all URLs fail, the workflow exits safely here.

    Execution prompt · Source text。Code node 'Filter Reachable' jsCode field

    // 1. Get all items from the current node (Reachability Check)
    const items = $input.all();
    
    // 2. Map through them to output only the two fields you want
    return items.map((item, index) => {
      
      // REACHBACK: Get the URL from the Split node using the index
      const splitNodeData = $('Split Valid URLs').all();
      const url = splitNodeData[index]?.json?.valid_urls || "URL Not Found";
      
      // DATA EXTRACTION: Pull status from the Reachability node
      const statusCode = item.json.statusCode || item.json.metadata?.statusCode;
    
      return {
        json: {
          valid_urls: url,
          status: statusCode
        }
      };
    });
  4. PageSpeed API Loop and Data Extraction

    Process reachable URLs one by one via a Loop node. Each URL triggers a GET to Google PageSpeed Insights API (v5/runPagespeed) with mobile strategy and categories=performance,seo,best-practices,accessibility. A 10-second Wait node paces requests. After each response, a Code node parses lighthouseResult to extract 40+ fields including Core Web Vitals, resource sizes, and savings opportunities, flattening them into table rows.

    Execution prompt · Source text。Code node 'Extract PageSpeed Data' jsCode field

    const items = $input.all();
    let results = [];
    
    for (const entry of items) {
      const item = entry.json;
      const lh = item.lighthouseResult;
    
      // 1. RESILIENCE: Catch missing Lighthouse data
      if (!lh) {
        results.push({
          "Address": item.id || "Unknown URL",
          "PSI Request Status": "Error: No Lighthouse Data",
          "Performance Score": 0
        });
        continue;
      }
    
      const audits = lh.audits || {};
      const categories = lh.categories || {};
    
      // HELPER: Resource Summary
      const getRes = (type) => {
        const summary = audits['resource-summary'];
        const resItems = (summary && summary.details && summary.details.items) ? summary.details.items : [];
        const res = resItems.find(i => i.resourceType === type);
        return { size: res ? res.transferSize : 0, count: res ? res.requestCount : 0 };
      };
    
      // HELPER: Savings (Updated for v13 Insight Schema)
      const getSav = (id) => {
        const audit = audits[id];
        if (!audit) return { bytes: 0, ms: 0 };
        
        const details = audit.details || {};
        const metricSavings = audit.metricSavings || {};
        const debugData = details.debugData || {};
    
        // Logic to handle varying locations of savings data in modern audits
        const bytes = details.overallSavingsBytes || details.wastedBytes || debugData.wastedBytes || 0;
        const ms = details.overallSavingsMs || details.wastedMs || audit.numericValue || metricSavings.FCP || metricSavings.LCP || 0;
        
        return { bytes, ms };
      };
    
      // HELPER: Category Mapper
      const getCat = (score) => {
        if (score === null || score === undefined) return "N/A";
        if (score >= 0.9) return "Good";
        if (score >= 0.5) return "Needs Improvement";
        return "Poor";
      };
    
      // Pre-fetch nested insight objects to avoid long logical chains
      const docLatency = audits['document-latency-insight'];
      const lcpDiscovery = audits['lcp-discovery-insight'];
      const netTree = audits['network-dependency-tree-insight'];
      const thirdParties = audits['third-parties-insight'];
    
      results.push({
        "Address": lh.finalUrl || item.id,
        "PSI Request Status": "Success",
        "Performance Score": Math.round((categories.performance ? categories.performance.score : 0) * 100),
    
        // LAB METRICS
        "First Contentful Paint Time (ms)": audits['first-contentful-paint'] ? audits['first-contentful-paint'].numericValue : 0,
        "First Contentful Paint Score": Math.round((audits['first-contentful-paint'] ? audits['first-contentful-paint'].score : 0) * 100),
        "Speed Index Time (ms)": audits['speed-index'] ? audits['speed-index'].numericValue : 0,
        "Speed Index Score": Math.round((audits['speed-index'] ? audits['speed-index'].score : 0) * 100),
        "Largest Contentful Paint Time (ms)": audits['largest-contentful-paint'] ? audits['largest-contentful-paint'].numericValue : 0,
        "Largest Contentful Paint Score": Math.round((audits['largest-contentful-paint'] ? audits['largest-contentful-paint'].score : 0) * 100),
        "Time to Interactive (ms)": audits['interactive'] ? audits['interactive'].numericValue : 0,
        "Time to Interactive Score": Math.round((audits['interactive'] ? audits['interactive'].score : 0) * 100),
        "Max Potential First Input Delay (ms)": audits['max-potential-fid'] ? audits['max-potential-fid'].numericValue : 0,
        "Max Potential First Input Delay Score": Math.round((audits['max-potential-fid'] ? audits['max-potential-fid'].score : 0) * 100),
        "Total Blocking Time (ms)": audits['total-blocking-time'] ? audits['total-blocking-time'].numericValue : 0,
        "Total Blocking Time Score": Math.round((audits['total-blocking-time'] ? audits['total-blocking-time'].score : 0) * 100),
        "Cumulative Layout Shift": audits['cumulative-layout-shift'] ? audits['cumulative-layout-shift'].numericValue : 0,
        "Cumulative Layout Shift Score": Math.round((audits['cumulative-layout-shift'] ? audits['cumulative-layout-shift'].score : 0) * 100),
    
        // AGGREGATED SAVINGS
        "Total Size Savings (Bytes)": (getSav('unused-javascript').bytes + getSav('unused-css-rules').bytes),
        "Total Time Savings (ms)": getSav('render-blocking-insight').ms,
    
        // PAGE COMPOSITION
        "Total Requests": getRes('total').count,
        "Total Page Size (Bytes)": getRes('total').size,
        "HTML Size (Bytes)": getRes('document').size,
        "HTML Count": getRes('document').count,
        "Image Size (Bytes)": getRes('image').size,
        "Image Count": getRes('image').count,
        "CSS Size (Bytes)": getRes('stylesheet').size,
        "CSS Count": getRes('stylesheet').count,
        "JavaScript Size (Bytes)": getRes('script').size,
        "JavaScript Count": getRes('script').count,
        "Font Size (Bytes)": getRes('font').size,
        "Font Count": getRes('font').count,
        "Third Party Size (Bytes)": getRes('third-party').size,
        "Third Party Count": getRes('third-party').count,
    
        // GRANULAR SAVINGS
        "Minify CSS Savings (ms)": getSav('unminified-css').ms,
        "Minify CSS Savings (Bytes)": getSav('unminified-css').bytes,
        "Minify JavaScript Savings (ms)": getSav('unminified-javascript').ms,
        "Minify JavaScript Savings (Bytes)": getSav('unminified-javascript').bytes,
        "Reduce Unused CSS Savings (ms)": getSav('unused-css-rules').ms,
        "Reduce Unused CSS Savings (Bytes)": getSav('unused-css-rules').bytes,
        "Reduce Unused JavaScript Savings (ms)": getSav('unused-javascript').ms,
        "Reduce Unused JavaScript Savings (Bytes)": getSav('unused-javascript').bytes,
    
        // ADVANCED DIAGNOSTICS & INSIGHT MAPPINGS
        "JavaScript Execution Time (ms)": audits['bootup-time'] ? audits['bootup-time'].numericValue : 0,
        "JavaScript Execution Time Category": getCat(audits['bootup-time'] ? audits['bootup-time'].score : 0),
        "Minimize Main-Thread Work (ms)": audits['mainthread-work-breakdown'] ? audits['mainthread-work-breakdown'].numericValue : 0,
        "Minimize Main-Thread Work Category": getCat(audits['mainthread-work-breakdown'] ? audits['mainthread-work-breakdown'].score : 0),
        "Network Payload Size (Bytes)": audits['total-byte-weight'] ? audits['total-byte-weight'].numericValue : 0,
        "Accessibility Score": Math.round((categories.accessibility ? categories.accessibility.score : 0) * 100),
        "Server Responds Quickly": audits['server-response-time'] ? audits['server-response-time'].displayValue : "N/A",
        
        // Corrected logic for nested v13 Insight items
        "Applies Text Compression": (docLatency && docLatency.details && docLatency.details.items && docLatency.details.items.usesCompression && docLatency.details.items.usesCompression.value) ? "Yes" : "No",
        "LCP Request Discovery": (lcpDiscovery && lcpDiscovery.details && lcpDiscovery.details.items && lcpDiscovery.details.items.requestDiscoverable && lcpDiscovery.details.items.requestDiscoverable.value) ? "Optimized" : "Action Required",
        "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",
        
        "Render Blocking Requests Savings (ms)": getSav('render-blocking-insight').ms,
        "Preconnect Candidates Savings (ms)": getSav('uses-rel-preconnect').ms,
        "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,
        "Use Efficient Cache Lifetimes Savings (Bytes)": getSav('cache-insight').bytes,
        "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,
        "DOM Size": audits['dom-size-insight'] ? audits['dom-size-insight'].numericValue : 0,
        "Improve Image Delivery Savings (Bytes)": getSav('image-delivery-insight').bytes,
        "Forced Reflow Savings (ms)": getSav('forced-reflow-insight').ms,
        "Legacy JavaScript Savings (Bytes)": getSav('legacy-javascript-insight').bytes,
        "Duplicated JavaScript (Bytes)": getSav('duplicated-javascript-insight').bytes,
        "Font Display Savings (ms)": getSav('font-display-insight').ms,
        "3rd Parties": (thirdParties && thirdParties.details && thirdParties.details.items ? thirdParties.details.items.length : 0) + " resources found"
      });
    }
    
    return results.map(r => ({ json: r }));
  5. Generate CSV and Upload

    Compile all flattened URL data into a CSV via the Convert to File node, then POST it as multipart/form-data to uguu.se for temporary hosting. A final Code node extracts files[0].url from the response.

    Execution prompt · Source text。Code node 'Extract Link' jsCode field

    const url = $json.files?.[0]?.url;
    return [
      {
        json: {
          message: `<${url}>`
        }
      }
    ];
  6. Return Download Link

    Send the final message to the user via Chat Response node, containing the temporary CSV download link. This node has executeOnce=true to ensure a single delivery.

Prompt you can copy

Setup guidance · Editorial suggestion。Hand to setup assistant; confirm inputs and permissions first

This block is the prompt. Ads are never placed here.

You are setting up an n8n workflow that bulk-fetches Google PageSpeed Insights performance metrics and exports them as CSV. First confirm you have: 1) an accessible n8n instance; 2) a Google PageSpeed Insights API Key added to n8n credential manager as httpQueryAuth. Then import workflow 15548 from the n8n template library, or configure the nodes manually per the steps above. Test with 1–2 URLs before running a full list.

Configure your Google API Key in n8n credentials before first run, or you will hit 429 rate limits immediately

Source

Export Google PageSpeed Insights data to CSV for multiple URLs · siddharth · n8n.io · 2026-05-08

Comments & feedback

Tried this method? Share results, problems or improvements. Comments appear after moderation. Please avoid sensitive information.

Loading…

    Related