AutomationFlowsGeneral › Automated Content SEO Audit Report

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: ★★★★☆

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

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 →