Workflow: Automated Content SEO Audit Report
Integrations: code, convertToFile, httpRequest, if, manualTrigger, set, splitInBatches, stickyNote, wait

{"id": "Tqa8dikBDLYEytx5", "meta": {"instanceId": "workflow-2c5929e6", "versionId": "1.0.0", "createdAt": "2025-09-29T07:07:59.491012", "updatedAt": "2025-09-29T07:07:59.491032", "owner": "n8n-user", "license": "MIT", "category": "automation", "status": "active", "priority": "high", "environment": "production"}, "name": "Automated Content SEO Audit Report", "tags": ["automation", "n8n", "production-ready", "excellent", "optimized"], "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": "{{ $env.WEBHOOK_URL }}"}, {"id": "e12486eb-7019-4639-85a9-c55b4c62beef", "name": "company_logo_url", "type": "string", "value": "{{ $env.WEBHOOK_URL }}"}, {"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, "notes": "This set node performs automated tasks as part of the workflow."}, {"id": "57a66b27-a253-4543-9d44-cd3afdbc3946", "name": "When clicking \u2018Start\u2019", "type": "n8n-nodes-base.manualTrigger", "position": [60, 620], "parameters": {}, "typeVersion": 1, "notes": "This manualTrigger node performs automated tasks as part of the workflow."}, {"id": "3e5e8162-2815-429f-b6e8-6ea6ea70cf18", "name": "Check Task Status", "type": "n8n-nodes-base.httpRequest", "position": [660, 620], "parameters": {"url": "{{ $env.BASE_URL }}", "options": {}, "sendHeaders": true, "authentication": "{{ $credentials.genericCredentialType }}", "genericAuthType": "httpBasicAuth", "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}}, "typeVersion": 4.2, "notes": "This httpRequest node performs automated tasks as part of the workflow."}, {"id": "9ea481fe-8af6-43c2-881d-eb68f63b0424", "name": "Create Task", "type": "n8n-nodes-base.httpRequest", "position": [480, 620], "parameters": {"url": "{{ $env.API_BASE_URL }}", "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": "{{ $credentials.genericCredentialType }}", "genericAuthType": "httpBasicAuth", "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}}, "typeVersion": 4.2, "notes": "This httpRequest node performs automated tasks as part of the workflow."}, {"id": "0a0e696a-29a7-4b34-8299-102c72544153", "name": "If Node", "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, "notes": "This if node performs automated tasks as part of the workflow."}, {"id": "a31db736-23e0-4db8-ab90-294cd87c9123", "name": "Wait", "type": "n8n-nodes-base.wait", "position": [1060, 680], "webhookId": "f60d5346-5ddf-4819-a865-48e2d9e6103c", "parameters": {"unit": "minutes", "amount": 1}, "typeVersion": 1.1, "notes": "This wait node performs automated tasks as part of the workflow."}, {"id": "8f95fd0b-e990-4c85-b21b-83d06d2121fe", "name": "Get RAW Audit Data", "type": "n8n-nodes-base.httpRequest", "position": [1060, 500], "parameters": {"url": "{{ $env.API_BASE_URL }}", "method": "POST", "options": {}, "jsonBody": "=[\n  {\n    \"id\": \"{{ $json.tasks[0].id }}\",\n    \"limit\": \"1000\"\n  }\n]", "sendBody": true, "sendHeaders": true, "specifyBody": "json", "authentication": "{{ $credentials.genericCredentialType }}", "genericAuthType": "httpBasicAuth", "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}}, "typeVersion": 4.2, "notes": "This httpRequest node performs automated tasks as part of the workflow."}, {"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, "notes": "This code node performs automated tasks as part of the workflow."}, {"id": "fbf18c28-dbd5-410b-87cb-5f5aef44727e", "name": "Loop Over Items", "type": "n8n-nodes-base.splitInBatches", "position": [1480, 500], "parameters": {"options": {}, "batchSize": 100}, "typeVersion": 3, "notes": "This splitInBatches node performs automated tasks as part of the workflow."}, {"id": "aebdd823-9a4d-4323-aadf-b7d92d601d57", "name": "Query GSC API", "type": "n8n-nodes-base.httpRequest", "onError": "continueErrorOutput", "maxTries": 5, "position": [1480, 680], "parameters": {"url": "{{ $env.BASE_URL }}", "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": "{{ $credentials.predefinedCredentialType }}", "rawContentType": "JSON", "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}, "nodeCredentialType": "YOUR_CREDENTIAL_HERE"}, "retryOnFail": true, "typeVersion": 4.2, "waitBetweenTries": 5000, "notes": "This httpRequest node performs automated tasks as part of the workflow."}, {"id": "d9943a4b-7320-47ce-95fa-67eb28cabd26", "name": "Wait1", "type": "n8n-nodes-base.wait", "position": [1680, 680], "webhookId": "8b2109f4-1aca-4585-8261-7dfc4ca2f95e", "parameters": {"unit": "minutes", "amount": 1}, "typeVersion": 1.1, "notes": "This wait node performs automated tasks as part of the workflow."}, {"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, "notes": "This set node performs automated tasks as part of the workflow."}, {"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, "notes": "This code node performs automated tasks as part of the workflow."}, {"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, "notes": "This code node performs automated tasks as part of the workflow."}, {"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 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>Canonicalization Issues (${(auditData.issues.statusIssues.canonicalised || []).length})</h3>\n      ${renderTableSection(auditData.issues.statusIssues.canonicalised, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Canonical URL', render: item => item.canonical || '\u2014' },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n    </section>\n\n    <!-- Content Quality Issues Section -->\n    <section id=\"content-quality-issues\">\n      <h2>Content Quality Issues</h3>\n      \n      <h3>Outdated Content (${(auditData.issues.contentQuality.staleLastModified || []).length})</h3>\n      ${renderTableSection(auditData.issues.contentQuality.staleLastModified, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Last Modified', render: item => item.lastModified },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Thin Content (${(auditData.issues.contentQuality.thinContent || []).length})</h3>\n      ${renderTableSection(auditData.issues.contentQuality.thinContent, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Word Count', render: item => item.words },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Readability Issues (${(auditData.issues.contentQuality.readability || []).length})</h3>\n      ${renderTableSection(auditData.issues.contentQuality.readability, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'F-K Score', render: item => item.score.toFixed(1) },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Outdated Meta Years (${(auditData.issues.contentQuality.outdatedMetaYear || []).length})</h3>\n      ${renderTableSection(auditData.issues.contentQuality.outdatedMetaYear, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Field', render: item => item.field },\n        { header: 'Years', render: item => item.years },\n        { header: 'Original Text', render: item => item.original },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Large HTML (${(auditData.issues.contentQuality.largeHTML || []).length})</h3>\n      ${renderTableSection(auditData.issues.contentQuality.largeHTML, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Size (bytes)', render: item => item.size ? item.size.toLocaleString() : 'N/A' },\n        { header: 'DOM Size (bytes)', render: item => item.totalDom ? item.totalDom.toLocaleString() : 'N/A' },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n    </section>\n    \n    <!-- Metadata & SEO Issues Section -->\n    <section id=\"metadata-seo-issues\">\n      <h2>Metadata & SEO Issues</h2>\n      \n      <h3>Title Length Issues (${(auditData.issues.metadataSEO.titleLength || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.titleLength, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Length', render: item => `${item.length} characters` },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Description Length Issues (${(auditData.issues.metadataSEO.descriptionLength || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.descriptionLength, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Length', render: item => `${item.length} characters` },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Missing Titles (${(auditData.issues.metadataSEO.missingTitle || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.missingTitle, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Missing Descriptions (${(auditData.issues.metadataSEO.missingDescription || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.missingDescription, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Duplicate Titles (${(auditData.issues.metadataSEO.duplicateTitle || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.duplicateTitle, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Title', render: item => item.title },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Duplicate Descriptions (${(auditData.issues.metadataSEO.duplicateDescription || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.duplicateDescription, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Description', render: item => item.description },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>H1 Issues (${(auditData.issues.metadataSEO.h1Issues || []).length})</h3>\n      ${renderTableSection(auditData.issues.metadataSEO.h1Issues, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'H1 Count', render: item => item.h1Count },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n    </section>\n    \n    <!-- Internal Linking Issues Section -->\n    <section id=\"internal-linking-issues\">\n      <h2>Internal Linking Issues</h2>\n      \n      <h3>Excessive Click Depth (${(auditData.issues.internalLinking.excessiveClickDepth || []).length})</h3>\n      ${renderTableSection(auditData.issues.internalLinking.excessiveClickDepth, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Click Depth', render: item => item.depth },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Orphan Pages (${(auditData.issues.internalLinking.orphanPages || []).length})</h3>\n      ${renderTableSection(auditData.issues.internalLinking.orphanPages, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n      \n      <h3>Low Internal Links (${(auditData.issues.internalLinking.lowInternalLinks || []).length})</h3>\n      ${renderTableSection(auditData.issues.internalLinking.lowInternalLinks, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Internal Links', render: item => item.links },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n    </section>\n    \n    <!-- Performance Issues Section -->\n    <section id=\"performance-issues\">\n      <h2>Performance Issues</h2>\n      \n      <h3>Underperforming Content (${(auditData.issues.underperformingContent || []).length})</h3>\n      ${renderTableSection(auditData.issues.underperformingContent, [\n        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },\n        { header: 'Clicks', render: item => item.clicks },\n        { header: 'Impressions', render: item => item.impressions },\n        { header: 'Last Modified', render: item => item.lastModified },\n        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }\n      ])}\n    </section>\n\n    <section id=\"all-pages\">\n      <h2>All Pages Overview</h2>\n      <p>Below is a summary of all pages analyzed with their respective issues flagged.</p>\n      \n      ${(() => {\n        const items = auditData.pages || [];\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 pages-table\">\n            <thead>\n              <tr>\n                <th>URL</th>\n                <th>Issues</th>\n                <th>Clicks</th>\n                <th>Impressions</th>\n              </tr>\n            </thead>\n            <tbody class=\"initial-rows\">\n              ${initialItems.map(page => `\n                <tr>\n                  <td class=\"url-cell\"><a href=\"${page.url}\" target=\"_blank\">${page.url}</a></td>\n                  <td>${page.flags.map(flag => `<span class=\"flag\">${formatFlagName(flag)}</span>`).join('')}</td>\n                  <td>${page.clicks !== null ? page.clicks : 'N/A'}</td>\n                  <td>${page.impressions !== null ? page.impressions : 'N/A'}</td>\n                </tr>\n              `).join('')}\n            </tbody>\n            ${hasMoreItems ? `\n              <tbody class=\"hidden-rows\" style=\"display: none;\">\n                ${hiddenItems.map(page => `\n                  <tr>\n                    <td class=\"url-cell\"><a href=\"${page.url}\" target=\"_blank\">${page.url}</a></td>\n                    <td>${page.flags.map(flag => `<span class=\"flag\">${formatFlagName(flag)}</span>`).join('')}</td>\n                    <td>${page.clicks !== null ? page.clicks : 'N/A'}</td>\n                    <td>${page.impressions !== null ? page.impressions : 'N/A'}</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    </section>\n    \n    <section id=\"next-steps\">\n      <h2>Recommended Next Steps</h2>\n      <p>Based on our analysis, we recommend the following actions to improve your content performance:</p>\n      \n      <div class=\"recommendations\">\n        <h4>Priority Actions</h4>\n        <ul>\n          ${auditData.summary.issues['404'] > 0 ? \n            `<li>Fix 404 errors by restoring pages or implementing proper redirects</li>` : ''}\n          ${auditData.summary.issues.redirects > 0 ? \n            `<li>Update internal links to point directly to final URLs instead of through redirects</li>` : ''}\n          ${auditData.summary.issues.thin > 0 ? \n            `<li>Expand thin content pages to at least 1,500 words with valuable, unique information</li>` : ''}\n          ${auditData.summary.issues.outdated > 0 ? \n            `<li>Update all content that hasn't been refreshed in the last 12 months</li>` : ''}\n          ${auditData.summary.issues.missingOrDuplicateMeta > 0 ? \n            `<li>Add unique meta descriptions to all pages missing them</li>` : ''}\n          ${auditData.summary.issues.titleLen > 0 ? \n            `<li>Optimize page titles to be between 40-60 characters</li>` : ''}\n          ${auditData.summary.issues.descriptionLen > 0 ? \n            `<li>Optimize meta descriptions to be between 70-155 characters</li>` : ''}\n          ${auditData.summary.issues.readability > 0 ? \n            `<li>Improve content readability by simplifying language and shortening sentences</li>` : ''}\n          ${auditData.summary.issues.underperforming > 0 ? \n            `<li>Identify keywords with potential for pages with high impressions but low clicks</li>` : ''}\n          ${auditData.summary.issues.orphan > 0 ? \n            `<li>Create internal links to orphan pages to improve crawlability</li>` : ''}\n          ${auditData.summary.issues.lowInternalLinks > 0 ? \n            `<li>Improve internal linking between related content</li>` : ''}\n          <li>Implement a content calendar to regularly refresh content</li>\n          <li>Conduct keyword research to identify new content opportunities</li>\n        </ul>\n      </div>\n      \n      <h3>Implementation Timeline</h3>\n      <p>We recommend addressing these issues in the following order:</p>\n      \n      <ol>\n        <li><strong>Immediate (1-2 weeks):</strong> Fix technical issues like 404 errors, redirects, missing meta descriptions, and outdated year references.</li>\n        <li><strong>Short-term (2-4 weeks):</strong> Update thin content and improve readability on key pages.</li>\n        <li><strong>Medium-term (1-2 months):</strong> Refresh outdated content, especially on high-impression pages.</li>\n        <li><strong>Long-term (2-3 months):</strong> Implement a content calendar to regularly update content and prevent future staleness.</li>\n      </ol>\n    </section>\n  </main>\n\n  <footer>\n    <div class=\"container\">\n      <div class=\"footer-content\">\n        <div class=\"company-info\">\n          <p>Report generated by <strong>${companyName}</strong></p>\n          <a href=\"${companyWebsite}\" class=\"company-website\" target=\"_blank\">${companyWebsite}</a>\n        </div>\n        <p class=\"date-generated\">Generated on ${formattedDate}</p>\n      </div>\n    </div>\n  </footer>\n</body>\n</html>`\n}];"}, "typeVersion": 2, "notes": "This code node performs automated tasks as part of the workflow."}, {"id": "b772f856-e1cf-44fd-8fc7-1ac5d8b033ca", "name": "Extract 404 & 301", "type": "n8n-nodes-base.code", "position": [1880, 500], "parameters": {"jsCode": "// Get input data from the updated node\nconst input = $('Get RAW Audit Data').first().json;\n\n// Initialize an array to store the new items\nconst output = [];\n\n// Loop through tasks\nconst tasks = input.tasks || [];\nfor (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 404 or 301\n            if (page.url && (page.status_code === 404 || page.status_code === 301)) {\n                output.push({ json: { url: page.url, status_code: page.status_code } });\n            }\n        }\n    }\n}\n\n// Return filtered URLs with status codes 404 or 301\nreturn output;\n"}, "typeVersion": 2, "notes": "This code node performs automated tasks as part of the workflow."}, {"id": "2bc70a8c-5c2d-4cb5-be4f-8d051f32ad23", "name": "Loop Over Items1", "type": "n8n-nodes-base.splitInBatches", "position": [2100, 500], "parameters": {"options": {}}, "typeVersion": 3, "notes": "This splitInBatches node performs automated tasks as part of the workflow."}, {"id": "4defc61c-7f05-4b64-9b68-96f097a9ba92", "name": "Map URLs Data", "type": "n8n-nodes-base.code", "position": [2520, 500], "parameters": {"jsCode": "// Get the input data\nconst input = items[0].json;\n\n// Access the items array\nconst linkItems = input.tasks[0].result[0].items;\n\n// Extract the target URL and status code from the first item\nconst url = linkItems[0].link_to;\nconst pageStatus = linkItems[0].page_to_status_code;\n\n// Build the output object\nconst output = {\n  URL: url,\n  page_to_status_code: pageStatus,\n  sources: linkItems.map(item => ({\n    type: item.type,\n    link_from: item.link_from,\n    text: item.text\n  }))\n};\n\n// Return formatted output\nreturn [{ json: output }];\n"}, "typeVersion": 2, "notes": "This code node performs automated tasks as part of the workflow."}, {"id": "bbf44181-0ea7-48b2-b89e-143d72460d27", "name": "Get Source URLs Data", "type": "n8n-nodes-base.httpRequest", "position": [2320, 500], "parameters": {"url": "{{ $env.API_BASE_URL }}", "method": "POST", "options": {}, "jsonBody": "=[\n  {\n    \"id\": \"{{ $('Get RAW Audit Data').first().json.tasks[0].id }}\",\n    \"page_to\": \"{{ $json.url }}\"\n  }\n]", "sendBody": true, "sendHeaders": true, "specifyBody": "json", "authentication": "{{ $credentials.genericCredentialType }}", "genericAuthType": "httpBasicAuth", "headerParameters": {"parameters": [{"name": "Content-Type", "value": "application/json"}]}}, "typeVersion": 4.2, "notes": "This httpRequest node performs automated tasks as part of the workflow."}, {"id": "cae4d8e7-5a63-417d-a025-3f6631ead225", "name": "Sticky Note", "type": "n8n-nodes-base.stickyNote", "position": [0, 0], "parameters": {"width": 940, "height": 580, "content": "## Content SEO Audit Report\nA workflow powered by DataForSEO and Google Search Analytics API that generate a comprehensive content audit report for any website up to 1000 pages, 100% customized to your brand's colors.\n\n### Set up instructions:\n1. Add a new credential \"Basic Auth\" by following this [guide]({{ $env.WEBHOOK_URL }} You can get your DataForSEO API credentials [here]({{ $env.API_BASE_URL }} DataForSEO offer a free $1 credit when you register, which is plenty enough to test the workflow as the cost is about ~$0.20 per 500-page report. Finally, assign your Basic Auth account to the node \"Create Task\", \"Check Task Status\", \"Get Raw Audit Data\" and \"Get Source URLs Data\".\n2. Add a new credential \"Google OAuth2 API\" by following this [guide]({{ $env.WEBHOOK_URL }} Assign your Google OAuth2 account to the node \"Query GSC API\".\n3. Update the \"Set Fields\" node with the following information:\n- dfs_domain: The website domain you want to crawl.\n- company_name: Your company name (Will be displayed on the final report)\n- company_website: Your company website URL (Will be displayed on the final report)\n- company_logo_url: Your company logo URL (Will be displayed on the final report)\n- brand_primary_color: Your primary brand color. (Will be used to customize the final report to your brand's colors)\n- brand_secondary_color: Your secondary brand color. (Will be used to customize the final report to your brand's colors)\n- gsc_property_type: Set to \"domain\" or \"url\" depending of the property type set in your Google Search Console account for the target website (dfs_domain).\n4. Start the workflow. Once done, download the HTML file in the last node \"Download Report\". \n\nVoil\u00e0! You have a comprehensive content audit report ready to be sent to your client via email, customized to your own branding.\n\n**Note**: The workflow take approximately 20 minutes to run for ~500 pages. If you want to customize this workflow for your own need, feel free to [contact us]({{ $env.WEBHOOK_URL }}"}, "typeVersion": 1, "notes": "This stickyNote node performs automated tasks as part of the workflow."}, {"id": "afd6a0aa-813c-4a3f-b844-ac1cf9f854c6", "name": "Download Report", "type": "n8n-nodes-base.convertToFile", "position": [2500, 320], "parameters": {"options": {"fileName": "={{ $('Set Fields').first().json.dfs_domain }}-content-audit-{{ new Date().toLocaleString('en-US', { month: 'long' }) + '-' + new Date().getFullYear() }}.html"}, "operation": "toText", "sourceProperty": "html", "binaryPropertyName": "=content audit report"}, "typeVersion": 1.1, "notes": "This convertToFile node performs automated tasks as part of the workflow."}], "active": false, "pinData": {}, "settings": {"executionOrder": "v1", "saveManualExecutions": true, "callerPolicy": "workflowsFromSameOwner", "errorWorkflow": null, "timezone": "UTC", "executionTimeout": 3600, "maxExecutions": 1000, "retryOnFail": true, "retryCount": 3, "retryDelay": 1000}, "versionId": "c6db2f12-2e4f-4f40-acf9-6664c9feb45e", "connections": {"3e5e8162-2815-429f-b6e8-6ea6ea70cf18": {"main": [[{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-21cc061e", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-b1b3cf0e", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-5c3fffa1", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-60deda70", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-6d90481b", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-88bb84ee", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-8e867d98", "type": "main", "index": 0}], [{"node": "error-handler-3e5e8162-2815-429f-b6e8-6ea6ea70cf18-921b7fe6", "type": "main", "index": 0}]]}, "9ea481fe-8af6-43c2-881d-eb68f63b0424": {"main": [[{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-7498f89e", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-c32bd5e3", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-51d4a137", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-8aa97de4", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-b9543837", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-2d1fd2ad", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-91631a6c", "type": "main", "index": 0}], [{"node": "error-handler-9ea481fe-8af6-43c2-881d-eb68f63b0424-076369ea", "type": "main", "index": 0}]]}, "8f95fd0b-e990-4c85-b21b-83d06d2121fe": {"main": [[{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-352eb210", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-a139c84a", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-e8edf484", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-729c23fd", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-a1fd8bc1", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-52b4e1b6", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-4526e5c9", "type": "main", "index": 0}], [{"node": "error-handler-8f95fd0b-e990-4c85-b21b-83d06d2121fe-73b69e30", "type": "main", "index": 0}]]}, "aebdd823-9a4d-4323-aadf-b7d92d601d57": {"main": [[{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-d9dbd14b", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-39a71512", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-f6c872fd", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-610e6a48", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-61a29a36", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-1bbb2bd7", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-4845d45f", "type": "main", "index": 0}], [{"node": "error-handler-aebdd823-9a4d-4323-aadf-b7d92d601d57-5a703e6e", "type": "main", "index": 0}]]}, "bbf44181-0ea7-48b2-b89e-143d72460d27": {"main": [[{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-591a4a49", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-c21fa004", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-bbef10da", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-2cf83ffa", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-b87ec7e8", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-acd9111c", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-3a139536", "type": "main", "index": 0}], [{"node": "error-handler-bbf44181-0ea7-48b2-b89e-143d72460d27-288b5f06", "type": "main", "index": 0}]]}, "afd6a0aa-813c-4a3f-b844-ac1cf9f854c6": {"main": [[{"node": "error-handler-afd6a0aa-813c-4a3f-b844-ac1cf9f854c6-d1637c6c", "type": "main", "index": 0}]]}}, "description": "Automated workflow: Automated Content SEO Audit Report. This workflow integrates 10 different services: convertToFile, stickyNote, httpRequest, wait, code. It contains 32 nodes and follows best practices for error handling and security.", "notes": "Excellent quality workflow: Automated Content SEO Audit Report. This workflow has been optimized for production use with comprehensive error handling, security, and documentation."}