AutomationFlowsGeneral › Automated SEO Audit Report Generator

Automated SEO Audit Report Generator

Original n8n title: Automated Content SEO Audit Report

Automated Content SEO Audit Report. Uses manualTrigger, httpRequest, splitInBatches, stickyNote. Event-driven trigger; 21 nodes.

Event trigger★★★★☆ complexity21 nodesHTTP Request
General Trigger: Event Nodes: 21 Complexity: ★★★★☆ Added:

The workflow JSON

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

Download .json
{
  "id": "Tqa8dikBDLYEytx5",
  "name": "Automated Content SEO Audit Report",
  "tags": [],
  "nodes": [
    {
      "id": "b5f15675-35c9-42a1-b7eb-bfaf0b467a5a",
      "name": "Set Fields",
      "type": "n8n-nodes-base.set",
      "position": [
        280,
        620
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e71886f0-104f-412b-9fef-d2b3738cebf0",
              "name": "dfs_domain",
              "type": "string",
              "value": "yourclientdomain.com"
            },
            {
              "id": "de35327e-1e32-4996-970a-50b8953c7709",
              "name": "dfs_max_crawl_pages",
              "type": "string",
              "value": "1000"
            },
            {
              "id": "0d6b4d1a-e57d-4e38-8aa5-e2ea5589a089",
              "name": "dfs_enable_javascript",
              "type": "string",
              "value": "false"
            },
            {
              "id": "d699e487-ab74-483f-8cd8-cdcfaca567d7",
              "name": "company_name",
              "type": "string",
              "value": "Custom Workflows AI"
            },
            {
              "id": "da123535-f678-4331-973a-07711b7aaaac",
              "name": "company_website",
              "type": "string",
              "value": "https://customworkflows.ai"
            },
            {
              "id": "e12486eb-7019-4639-85a9-c55b4c62beef",
              "name": "company_logo_url",
              "type": "string",
              "value": "https://customworkflows.ai/images/logo.png"
            },
            {
              "id": "9eef2015-e89c-4930-82a5-972111c1a4fe",
              "name": "brand_primary_color",
              "type": "string",
              "value": "#252946"
            },
            {
              "id": "dd4ff260-6008-49ec-a0e6-ad5c177eb8df",
              "name": "brand_secondary_color",
              "type": "string",
              "value": "#0fd393"
            },
            {
              "id": "d71a4d91-c5bf-49c4-b7d0-64e84dad6153",
              "name": "gsc_property_type",
              "type": "string",
              "value": "domain"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "57a66b27-a253-4543-9d44-cd3afdbc3946",
      "name": "When clicking \u2018Start\u2019",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        60,
        620
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "3e5e8162-2815-429f-b6e8-6ea6ea70cf18",
      "name": "Check Task Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        660,
        620
      ],
      "parameters": {
        "url": "=https://api.dataforseo.com/v3/on_page/summary/{{ $json.tasks[0].id }}",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "9ea481fe-8af6-43c2-881d-eb68f63b0424",
      "name": "Create Task",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        480,
        620
      ],
      "parameters": {
        "url": "https://api.dataforseo.com/v3/on_page/task_post",
        "method": "POST",
        "options": {},
        "jsonBody": "=[\n  {\n    \"target\": \"{{ $json.dfs_domain }}\",\n    \"max_crawl_pages\": {{ $json.dfs_max_crawl_pages }},\n    \"load_resources\": false,\n    \"enable_javascript\": {{ $json.dfs_enable_javascript }},\n    \"custom_js\": \"meta = {}; meta.url = document.URL; meta;\",\n    \"tag\": \"{{ $json.dfs_domain + Math.floor(10000 + Math.random() * 90000) }}\"\n  }\n]",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0a0e696a-29a7-4b34-8299-102c72544153",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        860,
        620
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "7e13429d-9ead-4ae5-8ed6-c5730b05927d",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.tasks[0].result[0].crawl_progress }}",
              "rightValue": "finished"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "a31db736-23e0-4db8-ab90-294cd87c9123",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        1060,
        680
      ],
      "parameters": {
        "unit": "minutes",
        "amount": 1
      },
      "typeVersion": 1.1
    },
    {
      "id": "8f95fd0b-e990-4c85-b21b-83d06d2121fe",
      "name": "Get RAW Audit Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1060,
        500
      ],
      "parameters": {
        "url": "https://api.dataforseo.com/v3/on_page/pages",
        "method": "POST",
        "options": {},
        "jsonBody": "=[\n  {\n    \"id\": \"{{ $json.tasks[0].id }}\",\n    \"limit\": \"1000\"\n  }\n]",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "6cf221d9-c17e-4a5c-9c9a-c3176319df95",
      "name": "Extract URLs",
      "type": "n8n-nodes-base.code",
      "position": [
        1260,
        500
      ],
      "parameters": {
        "jsCode": "// Get input data from the previous node\nconst input = $input.all();\n\n// Initialize an array to store the new items\nconst output = [];\n\n// Loop through each input item\nfor (const item of input) {\n    const tasks = item.json.tasks || [];\n    for (const task of tasks) {\n        const results = task.result || [];\n        for (const result of results) {\n            const items = result.items || [];\n            for (const page of items) {\n                // Only include URLs with status_code 200\n                if (page.url && page.status_code === 200) {\n                    output.push({ json: { url: page.url } });\n                }\n            }\n        }\n    }\n}\n\n// Return all URLs with status code 200 as separate items\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "fbf18c28-dbd5-410b-87cb-5f5aef44727e",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        1480,
        500
      ],
      "parameters": {
        "options": {},
        "batchSize": 100
      },
      "typeVersion": 3
    },
    {
      "id": "aebdd823-9a4d-4323-aadf-b7d92d601d57",
      "name": "Query GSC API",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueErrorOutput",
      "maxTries": 5,
      "position": [
        1480,
        680
      ],
      "parameters": {
        "url": "={{ \n  $('Set Fields').first().json.gsc_property_type === 'domain' \n    ? 'https://searchconsole.googleapis.com/webmasters/v3/sites/' + \n      'sc-domain:' + \n      $node[\"Loop Over Items\"].json.url.replace(/https?:\\/\\/(www\\.)?([^\\/]+).*/, '$2') + \n      '/searchAnalytics/query' \n    : 'https://searchconsole.googleapis.com/webmasters/v3/sites/' + \n      encodeURIComponent(\n        $node[\"Loop Over Items\"].json.url.replace(/(https?:\\/\\/(?:www\\.)?[^\\/]+).*/, '$1')\n      ) + \n      '/searchAnalytics/query' \n}}",
        "body": "={\n  \"startDate\": \"{{ new Date(new Date().setDate(new Date().getDate() - 90)).toISOString().split('T')[0] }}\",\n  \"endDate\": \"{{ new Date().toISOString().split('T')[0] }}\",\n  \"dimensionFilterGroups\": [\n    {\n      \"filters\": [\n        {\n          \"dimension\": \"page\",\n          \"operator\": \"equals\",\n          \"expression\": \"{{ $node['Loop Over Items'].json.url }}\"\n        }\n      ]\n    }\n  ],\n  \"aggregationType\": \"auto\",\n  \"rowLimit\": 100\n}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "rawContentType": "JSON",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "googleOAuth2Api"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "d9943a4b-7320-47ce-95fa-67eb28cabd26",
      "name": "Wait1",
      "type": "n8n-nodes-base.wait",
      "position": [
        1680,
        680
      ],
      "parameters": {
        "unit": "minutes",
        "amount": 1
      },
      "typeVersion": 1.1
    },
    {
      "id": "f2f7e975-1db1-4566-b674-396ccaa775f5",
      "name": "Map GSC Data to URL",
      "type": "n8n-nodes-base.set",
      "position": [
        1880,
        680
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "342ff66d-cdfc-46e8-9605-db588c913eb0",
              "name": "URL",
              "type": "string",
              "value": "={{ $('Loop Over Items').item.json.url }}"
            },
            {
              "id": "5c547efc-0514-4641-8f05-c24b965993ad",
              "name": "Clicks",
              "type": "string",
              "value": "={{ $('Query GSC API').item.json.rows[0].clicks }}"
            },
            {
              "id": "340c3ced-061d-49f0-911d-bd8b9e433a7d",
              "name": "Impressions",
              "type": "string",
              "value": "={{ $('Query GSC API').item.json.rows[0].impressions }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "4e42e1eb-4769-4e28-9f2f-3fb342baf971",
      "name": "Merge GSC Data with RAW Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1680,
        500
      ],
      "parameters": {
        "jsCode": "/*\n * Function node\n * Inputs: none (reads data from other nodes)\n * Output: ONE item whose .json is the enriched audit object\n */\n\n// 1. ----  Get the raw audit JSON  ------------------------------------------\nlet rawAuditData = $node['Get RAW Audit Data'].json;   // first item of that node\n\n// If that node delivered a JSON string, parse it:\nif (typeof rawAuditData === 'string') {\n\trawAuditData = JSON.parse(rawAuditData);\n}\n\n// 2. ----  Get the Google Search Console rows  ------------------------------\nconst gscItems = $items('Loop Over Items');            // all items from that node\n\n// 3. ----  Build a fast lookup:  URL -> { clicks, impressions }  ------------\nconst gscLookup = {};\nfor (const { json } of gscItems) {\n    const { URL, Clicks, Impressions } = json;\n    if (URL) {\n        gscLookup[URL] = {\n            clicks: Clicks !== undefined ? Number(Clicks) || 0 : null,\n            impressions: Impressions !== undefined ? Number(Impressions) || 0 : null,\n        };\n    }\n}\n\n// 4. ----  Enrich every page record with googleSearchConsoleData -------------\nconst itemsPath = (((rawAuditData.tasks || [])[0] || {}).result || [])[0]?.items || [];\n\nfor (const page of itemsPath) {\n    const url = page.url;\n    page.googleSearchConsoleData = gscLookup[url] || { clicks: null, impressions: null };\n}\n\n// 5. ----  Return ONE item with the updated audit data  ----------------------\nreturn [\n\t{\n\t\tjson: rawAuditData,   // <-- an actual object, so n8n is satisfied\n\t},\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "0b35fb68-6a0d-4eea-b29a-96550574c2b8",
      "name": "Build Report Structure",
      "type": "n8n-nodes-base.code",
      "position": [
        2100,
        320
      ],
      "parameters": {
        "jsCode": "/**\n * n8n \u2013 Function node\n * Input  : \u2022 One item whose `json` is the crawl + GSC data\n *          \u2022 All the items produced by the loop node \u201cLoop Over Items1\u201d\n * Output : ONE item whose `json` = { generatedAt, summary, issues, pages }\n *          \u2013 Unchanged shape, just extra `sources`[] on 404 / 301 records\n */\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 helpers & constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nconst CUR_YEAR          = new Date().getFullYear();\nconst YEAR_RX           = /20\\d{2}/g;\nconst TWELVE_MONTHS_MS  = 1000 * 60 * 60 * 24 * 365.25;\nconst SIX_MONTHS_MS     = TWELVE_MONTHS_MS / 2;\nconst LARGE_HTML_LIMIT  = 2_000_000;\n\nconst ageInMs      = (s) => Date.now() - Date.parse(s);\nconst ensureBucket = (parent, key) => (parent[key] ??= []);\nconst normalizeUrl = (u) => (u || '').replace(/\\/+$/, '');   // strip trailing \u201c/\u201d\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 main data sets \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nconst root  = $node['Merge GSC Data with RAW Data'].json;\nconst pages = root.tasks?.[0]?.result?.[0]?.items ?? [];\n\n/* link-source items from the loop node */\nconst sourceItems = $items('Loop Over Items1') ?? [];\nconst linkSourceMap = {};               // { normalisedTargetUrl : [ {linkFrom,type,text},\u2026 ] }\n\nfor (const itm of sourceItems) {\n  const j   = itm.json || {};\n  const tgt = normalizeUrl(j.URL);\n  if (!tgt) continue;\n\n  linkSourceMap[tgt] ??= [];\n  for (const s of j.sources || []) {\n    linkSourceMap[tgt].push({\n      linkFrom: s.link_from,\n      type    : s.type,\n      text    : s.text,\n    });\n  }\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 duplicate-meta look-ups \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nconst titleFreq = {};\nconst descFreq  = {};\n\nfor (const p of pages) {\n  const t = p.meta?.title?.trim();\n  const d = p.meta?.description?.trim();\n  if (t) titleFreq[t] = (titleFreq[t] || 0) + 1;\n  if (d) descFreq[d]  = (descFreq[d]  || 0) + 1;\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 report skeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nconst issues = {\n  statusIssues:         {},\n  contentQuality:       {},\n  metadataSEO:          {},\n  internalLinking:      {},\n  underperformingContent: [],\n};\n\nconst summary        = { pages: pages.length };\nconst pagesWithFlags = [];\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 per-page loop \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nfor (const p of pages) {\n  const url   = p.url;\n  const norm  = normalizeUrl(url);\n  const flags = [];\n\n  const add = (sect, bucket, rec) => ensureBucket(issues[sect], bucket).push(rec);\n\n  const isStatusOK = p.status_code === 200;\n\n  /* 1 \u00b7 404 ---------------------------------------------------- */\n  if (p.status_code === 404 || p.checks?.is_4xx_code) {\n    flags.push('404');\n    add('statusIssues', 'pages404', {\n      url,\n      sources: linkSourceMap[norm] ?? [],      // \u2190 new\n      todo  : 'Restore the page or 301-redirect it to a relevant URL.',\n    });\n  }\n\n  /* 2 \u00b7 301 ---------------------------------------------------- */\n  if (p.status_code === 301 || p.checks?.is_redirect) {\n    flags.push('redirect_301');\n    add('statusIssues', 'redirects301', {\n      url,\n      sources: linkSourceMap[norm] ?? [],      // \u2190 new\n      todo  : 'Update internal links so they point directly to the final URL (single-hop redirect).',\n    });\n  }\n\n  /* 3 \u00ad\u2013 15 \u00b7 all original checks (unchanged) ------------------ */\n  /* Canonicalised */\n  const canonicalised =\n      (p.meta?.canonical && p.meta.canonical !== url) ||\n      p.checks?.canonical_chain ||\n      p.checks?.recursive_canonical;\n\n  if (isStatusOK && canonicalised) {\n    flags.push('canonicalised');\n    add('statusIssues', 'canonicalised', {\n      url,\n      canonical: p.meta?.canonical,\n      todo: `Verify that \"${p.meta?.canonical || '\u2014'}\" is the correct canonical target and eliminate unintended duplicates.`,\n    });\n  }\n\n  /* Outdated content (years + stale last-modified) */\n  if (isStatusOK) {\n    const titleYears = (p.meta?.title?.match(YEAR_RX) || []).filter((y) => Number(y) < CUR_YEAR);\n    const descYears  = (p.meta?.description?.match(YEAR_RX) || []).filter((y) => Number(y) < CUR_YEAR);\n\n    if (titleYears.length) {\n      flags.push('outdated_year_title');\n      add('contentQuality', 'outdatedMetaYear', {\n        url,\n        field    : 'title',\n        years    : titleYears.join(','),\n        original : p.meta?.title,\n        todo     : `Title contains old year \u2192 ${titleYears.join(', ')}. Update to ${CUR_YEAR} or remove dates.`,\n      });\n    }\n    if (descYears.length) {\n      flags.push('outdated_year_description');\n      add('contentQuality', 'outdatedMetaYear', {\n        url,\n        field    : 'description',\n        years    : descYears.join(','),\n        original : p.meta?.description,\n        todo     : `Meta description contains old year \u2192 ${descYears.join(', ')}. Update to ${CUR_YEAR} or remove dates.`,\n      });\n    }\n\n    const lm = p.last_modified ??\n               p.meta?.social_media_tags?.['og:updated_time'] ?? null;\n\n    if (lm && ageInMs(lm) > TWELVE_MONTHS_MS) {\n      flags.push('stale_last_modified');\n      add('contentQuality', 'staleLastModified', {\n        url,\n        lastModified: lm,\n        todo        : 'Page not updated for 12+ months \u2014 refresh content.',\n      });\n    }\n  }\n\n  /* Thin content */\n  if (isStatusOK) {\n    const wc = p.meta?.content?.plain_text_word_count || 0;\n    if (p.click_depth !== 0 && wc >= 1 && wc <= 1500) {\n      flags.push('thin_content');\n      add('contentQuality', 'thinContent', {\n        url,\n        words: wc,\n        todo : 'Expand the piece beyond 1 500 words with valuable, unique information.',\n      });\n    }\n  }\n\n  /* Excessive click depth */\n  if (isStatusOK && (p.click_depth || 0) > 4) {\n    flags.push('excessive_click_depth');\n    add('internalLinking', 'excessiveClickDepth', {\n      url,\n      depth: p.click_depth,\n      todo : 'Surface this URL within \u22644 clicks via navigation or contextual links.',\n    });\n  }\n\n  /* Large HTML */\n  if (isStatusOK && ((p.size || 0) > LARGE_HTML_LIMIT || (p.total_dom_size || 0) > LARGE_HTML_LIMIT)) {\n    flags.push('large_html');\n    add('contentQuality', 'largeHTML', {\n      url,\n      size    : p.size,\n      totalDom: p.total_dom_size,\n      todo    : 'Reduce HTML payload (remove unused markup/JS, paginate, or lazy-load where possible).',\n    });\n  }\n\n  /* Title length */\n  if (isStatusOK && (p.meta?.title_length < 40 || p.meta?.title_length > 60)) {\n    flags.push('title_length');\n    add('metadataSEO', 'titleLength', {\n      url,\n      length: p.meta?.title_length,\n      todo  : `Write a meta title 40-60 characters long (currently ${p.meta?.title_length || 0}).`,\n    });\n  }\n\n  /* Description length */\n  if (isStatusOK) {\n    const dl = p.meta?.description_length || 0;\n    if (dl > 0 && (dl < 70 || dl > 155)) {\n      flags.push('description_length');\n      add('metadataSEO', 'descriptionLength', {\n        url,\n        length: dl,\n        todo  : `Write a meta description 70-155 characters long (currently ${dl}).`,\n      });\n    }\n  }\n\n  /* Missing / duplicate meta */\n  if (isStatusOK) {\n    if (p.checks?.no_title) {\n      flags.push('missing_title');\n      add('metadataSEO', 'missingTitle', { url, todo: 'Add a unique SEO title 40-60 characters long.' });\n    }\n    if (p.checks?.no_description) {\n      flags.push('missing_description');\n      add('metadataSEO', 'missingDescription', { url, todo: 'Add a unique meta description 70-155 characters long.' });\n    }\n    if (titleFreq[p.meta?.title?.trim()] > 1) {\n      flags.push('duplicate_title');\n      add('metadataSEO', 'duplicateTitle', { url, title: p.meta?.title, todo: 'Differentiate this title to avoid keyword cannibalisation.' });\n    }\n    if (p.meta?.description && descFreq[p.meta.description.trim()] > 1) {\n      flags.push('duplicate_description');\n      add('metadataSEO', 'duplicateDescription', { url, description: p.meta?.description, todo: 'Rewrite the meta description so each page is unique.' });\n    }\n  }\n\n  /* H1 issues */\n  if (isStatusOK) {\n    const h1s = p.meta?.htags?.h1 ?? [];\n    if (h1s.length !== 1) {\n      flags.push('h1_issue');\n      add('metadataSEO', 'h1Issues', { url, h1Count: h1s.length, todo: 'Ensure exactly one H1 tag per page that reflects the main topic.' });\n    }\n  }\n\n  /* Readability */\n  if (isStatusOK) {\n    const fk = p.meta?.content?.flesch_kincaid_readability_index ?? 100;\n    if (fk < 55) {\n      flags.push('low_readability');\n      add('contentQuality', 'readability', { url, score: fk, todo: `Simplify language, shorten sentences, and use lists to lift F-K score > 55 (currently ${fk.toFixed(2)}).` });\n    }\n  }\n\n  /* Orphan pages */\n  if (isStatusOK && p.checks?.is_orphan_page) {\n    flags.push('orphan_page');\n    add('internalLinking', 'orphanPages', { url, todo: 'Add at least one crawlable internal link pointing to this URL.' });\n  }\n\n  /* Low internal links */\n  if (isStatusOK && (p.meta?.internal_links_count || 0) < 3) {\n    flags.push('low_internal_links');\n    add('internalLinking', 'lowInternalLinks', { url, links: p.meta?.inbound_links_count, todo: 'Add three or more relevant internal links to strengthen topical signals.' });\n  }\n\n  /* Under-performing content */\n  if (isStatusOK) {\n    const clicks      = p.googleSearchConsoleData?.clicks ?? null;\n    const impressions = p.googleSearchConsoleData?.impressions ?? null;\n    const lm          = p.last_modified ?? p.meta?.social_media_tags?.['og:updated_time'] ?? null;\n\n    if (clicks !== null && clicks < 50 && (lm === null || ageInMs(lm) > SIX_MONTHS_MS)) {\n      flags.push('underperforming');\n      issues.underperformingContent.push({\n        url,\n        clicks,\n        impressions,\n        lastModified: lm,\n        todo: `Only ${clicks} clicks in the last 90 days \u2014 refresh content, improve targeting, or consider pruning.`,\n      });\n    }\n  }\n\n  /* page-level flags record */\n  pagesWithFlags.push({\n    url,\n    flags,\n    clicks     : p.googleSearchConsoleData?.clicks,\n    impressions: p.googleSearchConsoleData?.impressions,\n  });\n}\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 executive summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nconst count = (sect, bucket) => issues[sect]?.[bucket]?.length || 0;\n\nsummary.issues = {\n  '404'                 : count('statusIssues', 'pages404'),\n  redirects             : count('statusIssues', 'redirects301'),\n  canonicalised         : count('statusIssues', 'canonicalised'),\n  outdated              : count('contentQuality', 'outdatedMetaYear') +\n                           count('contentQuality', 'staleLastModified'),\n  thin                  : count('contentQuality', 'thinContent'),\n  excessiveClickDepth   : count('internalLinking', 'excessiveClickDepth'),\n  largeHTML             : count('contentQuality', 'largeHTML'),\n  titleLen              : count('metadataSEO', 'titleLength'),\n  descriptionLen        : count('metadataSEO', 'descriptionLength'),\n  missingOrDuplicateMeta:\n      count('metadataSEO', 'missingTitle') +\n      count('metadataSEO', 'missingDescription') +\n      count('metadataSEO', 'duplicateTitle') +\n      count('metadataSEO', 'duplicateDescription'),\n  h1Issues              : count('metadataSEO', 'h1Issues'),\n  readability           : count('contentQuality', 'readability'),\n  orphan                : count('internalLinking', 'orphanPages'),\n  lowInternalLinks      : count('internalLinking', 'lowInternalLinks'),\n  underperforming       : issues.underperformingContent.length,\n};\n\n/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 final report \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\nreturn [{\n  json: {\n    generatedAt: new Date().toISOString(),\n    summary,\n    issues,\n    pages: pagesWithFlags,\n  },\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "2227e1c7-890a-4b99-ad20-5b5645ba884b",
      "name": "Generate HTML Report",
      "type": "n8n-nodes-base.code",
      "position": [
        2320,
        320
      ],
      "parameters": {
        "jsCode": "// Get the audit data and company information\nconst auditData = $('Build Report Structure').item.json;\nconst websiteDomain = $('Set Fields').first().json.dfs_domain;\nconst companyName = $('Set Fields').first().json.company_name;\nconst companyWebsite = $('Set Fields').first().json.company_website;\nconst companyLogoUrl = $('Set Fields').first().json.company_logo_url;\nconst primaryColor = $('Set Fields').first().json.brand_primary_color;\nconst secondaryColor = $('Set Fields').first().json.brand_secondary_color;\n\n// Format date nicely\nconst formattedDate = new Date(auditData.generatedAt).toLocaleDateString('en-US', {\n  year: 'numeric',\n  month: 'long',\n  day: 'numeric'\n});\n\n// Calculate total issues\nconst totalIssues = Object.values(auditData.summary.issues).reduce((sum, count) => sum + count, 0);\n\n// Define issue gravity weights for health score calculation\nconst issueGravity = {\n  // Content Quality\n  outdated: 2, // Medium\n  thin: 3, // High\n  readability: 1, // Low\n  largeHTML: 2, // Medium\n  // Technical SEO\n  '404': 3, // High\n  redirects: 2, // Medium\n  canonicalised: 3, // High\n  // On-Page SEO\n  titleLen: 1, // Low\n  descriptionLen: 1, // Low\n  missingOrDuplicateMeta: 1, // Low\n  h1Issues: 3, // High\n  // Internal Linking\n  excessiveClickDepth: 3, // High\n  orphan: 3, // High\n  lowInternalLinks: 3, // High\n  // Performance\n  underperforming: 3 // High\n};\n\n// Calculate health score based on issue gravity\nfunction calculateHealthScore(pages, issues) {\n  // Calculate weighted sum of issues\n  let weightedIssues = 0;\n  let maxPossibleWeightedIssues = 0;\n  \n  // Process each issue type with its gravity weight\n  for (const [issueType, count] of Object.entries(auditData.summary.issues)) {\n    const gravity = issueGravity[issueType] || 1; // Default to Low if not defined\n    weightedIssues += count * gravity;\n    \n    // Assume worst case: all pages have this issue\n    maxPossibleWeightedIssues += pages * gravity;\n  }\n  \n  // Cap the maximum penalty to avoid too severe scores with many pages\n  const maxPenalty = Math.min(pages * 5, 100);\n  \n  // Calculate score: start at 100 and subtract weighted penalty\n  const weightedPenalty = Math.min(maxPenalty, (weightedIssues / Math.max(1, pages)) * 2);\n  const score = 100 - weightedPenalty;\n  \n  return Math.max(0, Math.round(score));\n}\n\n// Get health score color based on value\nfunction getHealthScoreColor(score) {\n  if (score >= 80) return '#4caf50'; // Green\n  if (score >= 60) return '#ff9800'; // Orange\n  return '#f44336'; // Red\n}\n\n// Get top recommendations\nfunction getTopRecommendations(audit) {\n  const recommendations = [];\n  const priorityMap = {\n    3: \"high\",     // High gravity issues\n    2: \"medium\",   // Medium gravity issues\n    1: \"low\"       // Low gravity issues\n  };\n  \n  // Check for high gravity issues first\n  if ((audit.issues.contentQuality.thinContent || []).length > 0) {\n    recommendations.push({\n      text: \"Expand thin content pages to improve topical depth and authority\",\n      priority: priorityMap[issueGravity.thin] || \"high\"\n    });\n  }\n  \n  if ((audit.issues.statusIssues.pages404 || []).length > 0) {\n    recommendations.push({\n      text: \"Fix 404 errors by restoring pages or implementing proper redirects\",\n      priority: priorityMap[issueGravity['404']] || \"high\"\n    });\n  }\n  \n  if ((audit.issues.metadataSEO.h1Issues || []).length > 0) {\n    recommendations.push({\n      text: \"Fix H1 tag issues to improve on-page SEO and content hierarchy\",\n      priority: priorityMap[issueGravity.h1Issues] || \"high\"\n    });\n  }\n  \n  if ((audit.issues.internalLinking.orphanPages || []).length > 0) {\n    recommendations.push({\n      text: \"Create internal links to orphan pages to improve crawlability\",\n      priority: priorityMap[issueGravity.orphan] || \"high\"\n    });\n  }\n  \n  if ((audit.issues.underperformingContent || []).length > 0) {\n    recommendations.push({\n      text: \"Optimize underperforming pages to improve search visibility\",\n      priority: priorityMap[issueGravity.underperforming] || \"high\"\n    });\n  }\n  \n  if ((audit.issues.statusIssues.canonicalised || []).length > 0) {\n    recommendations.push({\n      text: \"Fix canonicalization issues to consolidate ranking signals\",\n      priority: priorityMap[issueGravity.canonicalised] || \"high\"\n    });\n  }\n  \n  // Medium gravity issues\n  if ((audit.issues.contentQuality.staleLastModified || []).length > 0) {\n    recommendations.push({\n      text: \"Update stale content with fresh information and current year references\",\n      priority: priorityMap[issueGravity.outdated] || \"medium\"\n    });\n  }\n  \n  if ((audit.issues.statusIssues.redirects301 || []).length > 0) {\n    recommendations.push({\n      text: \"Update internal links to point directly to final URLs instead of through redirects\",\n      priority: priorityMap[issueGravity.redirects] || \"medium\"\n    });\n  }\n  \n  if ((audit.issues.contentQuality.largeHTML || []).length > 0) {\n    recommendations.push({\n      text: \"Reduce HTML size for better page performance and loading speed\",\n      priority: priorityMap[issueGravity.largeHTML] || \"medium\"\n    });\n  }\n  \n  // Low gravity issues\n  if ((audit.issues.metadataSEO.missingDescription || []).length > 0) {\n    recommendations.push({\n      text: \"Add missing meta descriptions to improve click-through rates\",\n      priority: priorityMap[issueGravity.missingOrDuplicateMeta] || \"low\"\n    });\n  }\n  \n  if ((audit.issues.contentQuality.readability || []).length > 0) {\n    recommendations.push({\n      text: \"Improve content readability to enhance user experience\",\n      priority: priorityMap[issueGravity.readability] || \"low\"\n    });\n  }\n  \n  // Fallback if not enough recommendations\n  if (recommendations.length < 3) {\n    recommendations.push({\n      text: \"Implement a regular content audit schedule to maintain freshness\",\n      priority: \"low\"\n    });\n  }\n  \n  // Return top 5 recommendations, prioritizing high gravity issues first\n  return recommendations\n    .sort((a, b) => {\n      const priorityOrder = { \"high\": 0, \"medium\": 1, \"low\": 2 };\n      return priorityOrder[a.priority] - priorityOrder[b.priority];\n    })\n    .slice(0, 5);\n}\n\n// Format flag names for display\nfunction formatFlagName(flag) {\n  return flag\n    .split('_')\n    .map(word => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(' ');\n}\n\n// Utility to lighten a color\nfunction lightenColor(hex, percent) {\n  hex = hex.replace('#', '');\n  let r = parseInt(hex.substring(0, 2), 16);\n  let g = parseInt(hex.substring(2, 4), 16);\n  let b = parseInt(hex.substring(4, 6), 16);\n  r = Math.min(255, Math.round(r + (255 - r) * (percent / 100)));\n  g = Math.min(255, Math.round(g + (255 - g) * (percent / 100)));\n  b = Math.min(255, Math.round(b + (255 - b) * (percent / 100)));\n  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;\n}\n\n// Utility to darken a color\nfunction darkenColor(hex, percent) {\n  hex = hex.replace('#', '');\n  let r = parseInt(hex.substring(0, 2), 16);\n  let g = parseInt(hex.substring(2, 4), 16);\n  let b = parseInt(hex.substring(4, 6), 16);\n  r = Math.max(0, Math.round(r * (1 - percent / 100)));\n  g = Math.max(0, Math.round(g * (1 - percent / 100)));\n  b = Math.max(0, Math.round(b * (1 - percent / 100)));\n  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;\n}\n\n// Helper function to render a table section or \"No issues found\" message\nfunction renderTableSection(items, columns) {\n  if (!items || items.length === 0) {\n    return `<p class=\"section-empty\">No issues found.</p>`;\n  }\n  \n  const showInitial = 10; // Number of rows to show initially\n  const hasMoreItems = items.length > showInitial;\n  const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;\n  const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];\n  \n  return `\n    <table class=\"paginated-table\">\n      <thead>\n        <tr>\n          ${columns.map(col => `<th>${col.header}</th>`).join('')}\n        </tr>\n      </thead>\n      <tbody class=\"initial-rows\">\n        ${initialItems.map(item => `\n          <tr>\n            ${columns.map(col => `<td class=\"${col.class || ''}\">${col.render(item)}</td>`).join('')}\n          </tr>\n        `).join('')}\n      </tbody>\n      ${hasMoreItems ? `\n        <tbody class=\"hidden-rows\" style=\"display: none;\">\n          ${hiddenItems.map(item => `\n            <tr>\n              ${columns.map(col => `<td class=\"${col.class || ''}\">${col.render(item)}</td>`).join('')}\n            </tr>\n          `).join('')}\n        </tbody>\n      ` : ''}\n    </table>\n    ${hasMoreItems ? `\n      <div class=\"table-pagination\">\n        <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>\n      </div>\n    ` : ''}\n  `;\n}\n\n// Helper function to render source links for 404 and 301 pages\nfunction renderSourceLinks(sources) {\n  if (!sources || sources.length === 0) {\n    return '<p class=\"no-sources\">No source links found.</p>';\n  }\n  \n  return `\n    <div class=\"source-links\">\n      <table class=\"source-links-table\">\n        <thead>\n          <tr>\n            <th>Source URL</th>\n            <th>Type</th>\n            <th>Anchor Text</th>\n          </tr>\n        </thead>\n        <tbody>\n          ${sources.map(source => `\n            <tr>\n              <td class=\"url-cell\"><a href=\"${source.linkFrom}\" target=\"_blank\">${source.linkFrom}</a></td>\n              <td>${source.type || 'N/A'}</td>\n              <td>${source.text || 'N/A'}</td>\n            </tr>\n          `).join('')}\n        </tbody>\n      </table>\n    </div>\n  `;\n}\n\n// Return a single item with the HTML content\nreturn [{\n  html: `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Content Audit Report for ${websiteDomain} | ${companyName}</title>\n  <style>\n    :root {\n      --primary-color: ${primaryColor};\n      --secondary-color: ${secondaryColor};\n      --primary-light: ${lightenColor(primaryColor, 85)};\n      --secondary-light: ${lightenColor(secondaryColor, 85)};\n      --primary-dark: ${darkenColor(primaryColor, 20)};\n      --text-color: #333;\n      --light-gray: #f5f5f5;\n      --medium-gray: #e0e0e0;\n      --dark-gray: #757575;\n      --success-color: #4caf50;\n      --warning-color: #ff9800;\n      --danger-color: #f44336;\n    }\n    \n    * {\n      margin: 0;\n      padding: 0;\n      box-sizing: border-box;\n    }\n    \n    body {\n      font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n      line-height: 1.6;\n      color: var(--text-color);\n      background-color: #fff;\n    }\n    \n    .container {\n      max-width: 1200px;\n      margin: 0 auto;\n      padding: 0 20px;\n    }\n    \n    header {\n      background-color: var(--primary-color);\n      color: white;\n      padding: 30px 0;\n      margin-bottom: 40px;\n    }\n    \n    .header-content {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n    }\n    \n    .logo-container {\n      display: flex;\n      align-items: center;\n    }\n    \n    .logo {\n      max-height: 60px;\n      margin-right: 20px;\n    }\n    \n    .report-info {\n      text-align: right;\n    }\n    \n    h1 {\n      font-size: 1.8rem;\n      margin-bottom: 0px;\n      color: white;\n    }\n    \n    h2 {\n      font-size: 1.8rem;\n      margin: 40px 0 20px;\n      color: var(--primary-color);\n      border-bottom: 2px solid var(--primary-light);\n      padding-bottom: 10px;\n    }\n    \n    h3 {\n      font-size: 1.4rem;\n      margin: 30px 0 15px;\n      color: var(--primary-dark);\n    }\n    \n    h4 {\n      font-size: 1.2rem;\n      margin: 20px 0 10px;\n      color: var(--secondary-color);\n    }\n    \n    p {\n      margin-bottom: 15px;\n    }\n    \n    .summary-cards {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n      gap: 20px;\n      margin: 30px 0;\n    }\n    \n    .card {\n      background-color: white;\n      border-radius: 8px;\n      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n      padding: 20px;\n      transition: transform 0.3s ease;\n    }\n    \n    .card:hover {\n      transform: translateY(-5px);\n    }\n    \n    .card-header {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 15px;\n    }\n    \n    .card-title {\n      font-size: 1.2rem;\n      font-weight: 600;\n      color: var(--primary-color);\n    }\n    \n    .card-value {\n      font-size: 2.5rem;\n      font-weight: 700;\n      color: var(--secondary-color);\n    }\n    \n    .issues-summary {\n      display: flex;\n      justify-content: space-between;\n      flex-wrap: wrap;\n      gap: 15px;\n      margin: 30px 0;\n    }\n    \n    .issue-category {\n      flex: 1;\n      min-width: 250px;\n      background-color: var(--light-gray);\n      border-radius: 8px;\n      padding: 20px;\n      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);\n    }\n    \n    .issue-category h3 {\n      color: var(--primary-color);\n      margin-top: 0;\n      border-bottom: 1px solid var(--medium-gray);\n      padding-bottom: 10px;\n    }\n    \n    .issue-item {\n      display: flex;\n      justify-content: space-between;\n      padding: 8px 0;\n      border-bottom: 1px solid var(--medium-gray);\n    }\n    \n    .issue-item:last-child {\n      border-bottom: none;\n    }\n    \n    .issue-name {\n      color: var(--text-color);\n    }\n    \n    .issue-count {\n      font-weight: 600;\n      color: var(--secondary-color);\n    }\n    \n    table {\n      width: 100%;\n      border-collapse: collapse;\n      margin: 20px 0 40px;\n      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n    }\n    \n    th {\n      background-color: var(--primary-color);\n      color: white;\n      text-align: left;\n      padding: 12px 15px;\n    }\n    \n    tr:nth-child(even) {\n      background-color: var(--light-gray);\n    }\n    \n    td {\n      padding: 10px 15px;\n      border-bottom: 1px solid var(--medium-gray);\n    }\n    \n    .url-cell {\n      max-width: 300px;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n    }\n    \n    .url-cell a {\n      color: var(--primary-color);\n      text-decoration: none;\n    }\n    \n    .url-cell a:hover {\n      text-decoration: underline;\n    }\n    \n    .todo-cell {\n      max-width: 400px;\n    }\n    \n    .flag {\n      display: inline-block;\n      padding: 3px 8px;\n      border-radius: 4px;\n      margin: 2px;\n      font-size: 0.8rem;\n      background-color: var(--primary-light);\n      color: var(--primary-dark);\n    }\n    \n    .pages-table {\n      margin-top: 30px;\n    }\n    \n    .pages-table th {\n      position: sticky;\n      top: 0;\n    }\n    \n    footer {\n      margin-top: 60px;\n      padding: 30px 0;\n      background-color: var(--primary-light);\n      color: var(--primary-dark);\n      text-align: center;\n    }\n    \n    .footer-content {\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n    }\n    \n    .company-info {\n      margin-bottom: 20px;\n    }\n    \n    .company-website {\n      color: var(--primary-color);\n      text-decoration: none;\n      font-weight: 600;\n    }\n    \n    .company-website:hover {\n      text-decoration: underline;\n    }\n    \n    .date-generated {\n      font-style: italic;\n      color: var(--dark-gray);\n    }\n    \n    .progress-bar-container {\n      width: 100%;\n      background-color: var(--light-gray);\n      border-radius: 10px;\n      margin: 10px 0;\n      overflow: hidden;\n    }\n    \n    .progress-bar {\n      height: 10px;\n      background-color: var(--secondary-color);\n      border-radius: 10px;\n    }\n    \n    .recommendations {\n      background-color: var(--secondary-light);\n      border-left: 4px solid var(--secondary-color);\n      padding: 15px;\n      margin: 20px 0;\n      border-radius: 0 4px 4px 0;\n    }\n    \n    .recommendations h4 {\n      color: var(--secondary-color);\n      margin-top: 0;\n    }\n    \n    .recommendations ul {\n      margin-left: 20px;\n    }\n    \n    .recommendations li {\n      margin-bottom: 8px;\n    }\n    \n    .priority-tag {\n      display: inline-block;\n      padding: 3px 8px;\n      border-radius: 4px;\n      margin-left: 8px;\n      font-size: 0.8rem;\n      font-weight: 600;\n    }\n    \n    .high {\n      background-color: rgba(244, 67, 54, 0.1);\n      color: var(--danger-color);\n    }\n    \n    .medium {\n      background-color: rgba(255, 152, 0, 0.1);\n      color: var(--warning-color);\n    }\n    \n    .low {\n      background-color: rgba(76, 175, 80, 0.1);\n      color: var(--success-color);\n    }\n    \n    .section-empty {\n      font-style: italic;\n      color: var(--dark-gray);\n      padding: 15px;\n      background-color: var(--light-gray);\n      border-radius: 4px;\n      text-align: center;\n    }\n    \n    .source-links {\n      margin-top: 10px;\n      margin-bottom: 20px;\n      padding: 10px;\n      background-color: var(--light-gray);\n      border-radius: 4px;\n      border-left: 3px solid var(--secondary-color);\n    }\n    \n    .source-links h4 {\n      margin-top: 0;\n      margin-bottom: 10px;\n      color: var(--secondary-color);\n      font-size: 1rem;\n    }\n    \n    .source-links-table {\n      margin: 0;\n      box-shadow: none;\n    }\n    \n    .source-links-table th {\n      background-color: var(--secondary-color);\n      font-size: 0.9rem;\n      padding: 8px 10px;\n    }\n    \n    .source-links-table td {\n      font-size: 0.9rem;\n      padding: 6px 10px;\n    }\n    \n    .no-sources {\n      font-style: italic;\n      color: var(--dark-gray);\n      margin: 5px 0;\n    }\n    \n    .toggle-sources {\n      background-color: var(--secondary-light);\n      color: var(--secondary-color);\n      border: 1px solid var(--secondary-color);\n      border-radius: 4px;\n      padding: 5px 10px;\n      font-size: 0.8rem;\n      cursor: pointer;\n      margin-top: 5px;\n      transition: background-color 0.3s;\n    }\n    \n    .toggle-sources:hover {\n      background-color: var(--secondary-color);\n      color: white;\n    }\n    \n    .sources-container {\n      margin-top: 10px;\n    }\n    \n    .show-more-button {\n      background-color: var(--primary-color);\n      color: white;\n      border: none;\n      border-radius: 4px;\n      padding: 8px 16px;\n      font-size: 0.9rem;\n      font-weight: 600;\n      cursor: pointer;\n      margin: 10px auto;\n      display: block;\n      transition: all 0.3s ease;\n      box-shadow: 0 2px 5px rgba(0,0,0,0.1);\n    }\n    \n    .show-more-button:hover {\n      background-color: var(--primary-dark);\n      box-shadow: 0 3px 7px rgba(0,0,0,0.2);\n      transform: translateY(-2px);\n    }\n    \n    .table-pagination {\n      text-align: center;\n      margin-top: -20px;\n      margin-bottom: 30px;\n    }\n    \n    @media print {\n      body {\n        font-size: 12pt;\n      }\n      \n      .container {\n        width: 100%;\n        max-width: none;\n        padding: 0;\n      }\n      \n      header {\n        padding: 15px 0;\n      }\n      \n      h1 {\n        font-size: 20pt;\n      }\n      \n      h2 {\n        font-size: 18pt;\n        margin-top: 20px;\n      }\n      \n      h3 {\n        font-size: 14pt;\n      }\n      \n      .card:hover {\n        transform: none;\n      }\n      \n      table {\n        page-break-inside: avoid;\n      }\n      \n      tr {\n        page-break-inside: avoid;\n      }\n      \n      .no-print {\n        display: none;\n      }\n      \n      @page {\n        margin: 1.5cm;\n      }\n    }\n  </style>\n  <script>\n    // JavaScript to toggle source links visibility\n    document.addEventListener('DOMContentLoaded', function() {\n      document.querySelectorAll('.toggle-sources').forEach(button => {\n        button.addEventListener('click', function() {\n          const container = this.nextElementSibling;\n          if (container.style.display === 'none' || !container.style.display) {\n            container.style.display = 'block';\n            this.textContent = 'Hide Source Links';\n          } else {\n            container.style.display = 'none';\n            this.textContent = 'Show Source Links';\n          }\n        });\n      });\n    });\n    \n    // JavaScript to toggle table rows visibility\n    function toggleRows(button) {\n      const table = button.closest('.table-pagination').previousElementSibling;\n      const hiddenRows = table.querySelector('.hidden-rows');\n      const totalRows = hiddenRows.querySelectorAll('tr').length + table.querySelector('.initial-rows').querySelectorAll('tr').length;\n      \n      if (hiddenRows.style.display === 'none' || !hiddenRows.style.display) {\n        hiddenRows.style.display = 'table-row-group';\n        button.textContent = 'Show Less';\n      } else {\n        hiddenRows.style.display = 'none';\n        button.textContent = 'Show All (' + totalRows + ' items)';\n      }\n    }\n  </script>\n</head>\n<body>\n  <header>\n    <div class=\"container\">\n      <div class=\"header-content\">\n        <div class=\"logo-container\">\n          <img src=\"${companyLogoUrl}\" alt=\"${companyName} Logo\" class=\"logo\">\n          <div>\n            <h1>Content Audit Report</h1>\n            <p>for ${websiteDomain}</p>\n          </div>\n        </div>\n        <div class=\"report-info\">\n          <p>Generated on: ${formattedDate}</p>\n          <p>By: ${companyName}</p>\n        </div>\n      </div>\n    </div>\n  </header>\n\n  <main class=\"container\">\n    <section id=\"executive-summary\">\n      <h2>Executive Summary</h2>\n      <p>This report provides a comprehensive analysis of content issues found on <strong>${websiteDomain}</strong>. We've identified ${totalIssues} issues across ${auditData.summary.pages} pages that need attention to improve SEO performance and user experience.</p>\n      \n      <div class=\"summary-cards\">\n        <div class=\"card\">\n          <div class=\"card-header\">\n            <span class=\"card-title\">Pages Analyzed</span>\n          </div>\n          <div class=\"card-value\">${auditData.summary.pages}</div>\n        </div>\n        \n        <div class=\"card\">\n          <div class=\"card-header\">\n            <span class=\"card-title\">Total Issues</span>\n          </div>\n          <div class=\"card-value\">${totalIssues}</div>\n        </div>\n        \n        <div class=\"card\">\n          <div class=\"card-header\">\n            <span class=\"card-title\">Health Score</span>\n          </div>\n          <div class=\"card-value\" style=\"color: ${getHealthScoreColor(calculateHealthScore(auditData.summary.pages, totalIssues))};\">${calculateHealthScore(auditData.summary.pages, totalIssues)}%</div>\n          <div class=\"progress-bar-container\">\n            <div class=\"progress-bar\" style=\"width: ${calculateHealthScore(auditData.summary.pages, totalIssues)}%\"></div>\n          </div>\n        </div>\n      </div>\n      \n      <div class=\"recommendations\">\n        <h4>Key Recommendations</h4>\n        <ul>\n          ${getTopRecommendations(auditData).map(rec => `<li>${rec.text} <span class=\"priority-tag ${rec.priority}\">${rec.priority}</span></li>`).join('')}\n        </ul>\n      </div>\n    </section>\n\n    <section id=\"issues-breakdown\">\n      <h2>Issues Breakdown</h2>\n      \n      <div class=\"issues-summary\">\n        <div class=\"issue-category\">\n          <h3>Content Quality</h3>\n          <div class=\"issues-list\">\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Outdated Content</span>\n              <span class=\"issue-count\">${auditData.summary.issues.outdated}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Thin Content</span>\n              <span class=\"issue-count\">${auditData.summary.issues.thin}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Readability Issues</span>\n              <span class=\"issue-count\">${auditData.summary.issues.readability}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Large HTML</span>\n              <span class=\"issue-count\">${auditData.summary.issues.largeHTML}</span>\n            </div>\n          </div>\n        </div>\n        \n        <div class=\"issue-category\">\n          <h3>Technical SEO</h3>\n          <div class=\"issues-list\">\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">404 Errors</span>\n              <span class=\"issue-count\">${auditData.summary.issues['404']}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Redirects</span>\n              <span class=\"issue-count\">${auditData.summary.issues.redirects}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Canonicalization Issues</span>\n              <span class=\"issue-count\">${auditData.summary.issues.canonicalised}</span>\n            </div>\n          </div>\n        </div>\n        \n        <div class=\"issue-category\">\n          <h3>On-Page SEO</h3>\n          <div class=\"issues-list\">\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Title Length Issues</span>\n              <span class=\"issue-count\">${auditData.summary.issues.titleLen}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Description Issues</span>\n              <span class=\"issue-count\">${auditData.summary.issues.descriptionLen}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Missing/Duplicate Meta</span>\n              <span class=\"issue-count\">${auditData.summary.issues.missingOrDuplicateMeta}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">H1 Issues</span>\n              <span class=\"issue-count\">${auditData.summary.issues.h1Issues}</span>\n            </div>\n          </div>\n        </div>\n        \n        <div class=\"issue-category\">\n          <h3>Internal Linking</h3>\n          <div class=\"issues-list\">\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Excessive Click Depth</span>\n              <span class=\"issue-count\">${auditData.summary.issues.excessiveClickDepth}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Orphan Pages</span>\n              <span class=\"issue-count\">${auditData.summary.issues.orphan}</span>\n            </div>\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Low Internal Links</span>\n              <span class=\"issue-count\">${auditData.summary.issues.lowInternalLinks}</span>\n            </div>\n          </div>\n        </div>\n        \n        <div class=\"issue-category\">\n          <h3>Performance</h3>\n          <div class=\"issues-list\">\n            <div class=\"issue-item\">\n              <span class=\"issue-name\">Underperforming Pages</span>\n              <span class=\"issue-count\">${auditData.summary.issues.underperforming}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n\n    <!-- Status Issues Section -->\n    <section id=\"status-issues\">\n      <h2>Status Issues</h2>\n      \n      <h3>404 Errors (${(auditData.issues.statusIssues.pages404 || []).length})</h3>\n      ${(auditData.issues.statusIssues.pages404 || []).length === 0 ? \n        `<p class=\"section-empty\">No issues found.</p>` : \n        (() => {\n          const items = auditData.issues.statusIssues.pages404 || [];\n          const showInitial = 10; // Number of rows to show initially\n          const hasMoreItems = items.length > showInitial;\n          const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;\n          const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];\n          \n          return `\n            <table class=\"paginated-table\">\n              <thead>\n                <tr>\n                  <th>URL</th>\n                  <th>Source Links</th>\n                  <th>Recommendation</th>\n                </tr>\n              </thead>\n              <tbody class=\"initial-rows\">\n                ${initialItems.map(item => `\n                  <tr>\n                    <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>\n                    <td>\n                      ${item.sources && item.sources.length > 0 ? \n                        `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>\n                        <div class=\"sources-container\" style=\"display: none;\">\n                          ${renderSourceLinks(item.sources)}\n                        </div>` : \n                        `<span class=\"no-sources\">No source links found</span>`\n                      }\n                    </td>\n                    <td class=\"todo-cell\">${item.todo}</td>\n                  </tr>\n                `).join('')}\n              </tbody>\n              ${hasMoreItems ? `\n                <tbody class=\"hidden-rows\" style=\"display: none;\">\n                  ${hiddenItems.map(item => `\n                    <tr>\n                      <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>\n                      <td>\n                        ${item.sources && item.sources.length > 0 ? \n                          `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>\n                          <div class=\"sources-container\" style=\"display: none;\">\n                            ${renderSourceLinks(item.sources)}\n                          </div>` : \n                          `<span class=\"no-sources\">No source links found</span>`\n                        }\n                      </td>\n                      <td class=\"todo-cell\">${item.todo}</td>\n                    </tr>\n                  `).join('')}\n                </tbody>\n              ` : ''}\n            </table>\n            ${hasMoreItems ? `\n              <div class=\"table-pagination\">\n                <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>\n              </div>\n            ` : ''}\n          `;\n        })()\n      }\n      \n      <h3>301 Redirects (${(auditData.issues.statusIssues.redirects301 || []).length})</h3>\n      ${(auditData.issues.statusIssues.redirects301 || []).length === 0 ? \n        `<p class=\"section-empty\">No issues found.</p>` : \n        (() => {\n          const items = auditData.issues.statusIssues.redirects301 || [];\n          const showInitial = 10; // Number of rows to show initially\n          const hasMoreItems = items.length > showInitial;\n          const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;\n          const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];\n          \n          return `\n            <table class=\"paginated-table\">\n              <thead>\n                <tr>\n                  <th>URL</th>\n                  <th>Source Links</th>\n                  <th>Recommendation</th>\n                </tr>\n              </thead>\n              <tbody class=\"initial-rows\">\n                ${initialItems.map(item => `\n                  <tr>\n                    <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>\n                    <td>\n                      ${item.sources && item.sources.length > 0 ? \n                        `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>\n                        <div class=\"sources-container\" style=\"display: none;\">\n                          ${renderSourceLinks(item.sources)}\n                        </div>` : \n                        `<span class=\"no-sources\">No source links found</span>`\n                      }\n                    </td>\n                    <td class=\"todo-cell\">${item.todo}</td>\n                  </tr>\n                `).join('')}\n              </tbody>\n              ${hasMoreItems ? `\n                <tbody class=\"hidden-rows\" style=\"display: none;\">\n                  ${hiddenItems.map(item => `\n                    <tr>\n                      <td class=\"url-cell\"><a hr
Pro

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

How this works

Business owners and content managers gain effortless insights into their website's SEO performance with this workflow, which automates the generation of detailed audit reports to identify optimisation opportunities and track improvements over time. It suits small teams or solo operators lacking dedicated SEO tools, delivering actionable recommendations without manual data crunching. The key step involves extracting URLs from raw audit data fetched via HTTP requests to a SEO service API, followed by processing to compile a comprehensive report.

Use this workflow for periodic SEO health checks on content-heavy sites, such as blogs or e-commerce pages, especially when integrating with APIs like Ahrefs or SEMrush for automated pulls. Avoid it for real-time monitoring or sites with thousands of pages, where simpler tools might suffice to prevent overload. Common variations include adding email notifications for report delivery or scheduling runs via cron triggers instead of manual starts.

About this workflow

Automated Content SEO Audit Report. Uses manualTrigger, httpRequest, splitInBatches, stickyNote. Event-driven trigger; 21 nodes.

Source: https://github.com/Zie619/n8n-workflows — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

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

General

Create Animated Stories using GPT-4o-mini, Midjourney, Kling and Creatomate API. Uses httpRequest, stickyNote, manualTrigger. Event-driven trigger; 51 nodes.

HTTP Request
General

Generate Leads with Google Maps - AlexK1919. Uses manualTrigger, scheduleTrigger, executeWorkflowTrigger, stopAndError. Event-driven trigger; 42 nodes.

Execute Workflow Trigger, Stop And Error, HTTP Request +1
General

Limit Code. Uses microsoftOutlookTrigger, httpRequest, limit, noOp. Event-driven trigger; 41 nodes.

Microsoft Outlook Trigger, HTTP Request
General

AutoQoutesV2_template. Uses manualTrigger, httpRequest, stickyNote, googleSheets. Event-driven trigger; 28 nodes.

HTTP Request, Google Sheets, Google Drive +2
General

Turn YouTube Videos into Summaries, Transcripts, and Visual Insights. Uses manualTrigger, stickyNote, httpRequest. Event-driven trigger; 26 nodes.

HTTP Request