{
"id": "Tqa8dikBDLYEytx5",
"meta": {
"instanceId": "ddfdf733df99a65c801a91865dba5b7c087c95cc22a459ff3647e6deddf2aee6"
},
"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.399999999999999911182158029987476766109466552734375
},
{
"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.20000000000000017763568394002504646778106689453125
},
{
"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.20000000000000017763568394002504646778106689453125
},
{
"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.20000000000000017763568394002504646778106689453125
},
{
"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.100000000000000088817841970012523233890533447265625
},
{
"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.20000000000000017763568394002504646778106689453125
},
{
"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.20000000000000017763568394002504646778106689453125,
"waitBetweenTries": 5000
},
{
"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.100000000000000088817841970012523233890533447265625
},
{
"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.399999999999999911182158029987476766109466552734375
},
{
"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 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
},
{
"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
},
{
"id": "2bc70a8c-5c2d-4cb5-be4f-8d051f32ad23",
"name": "Loop Over Items1",
"type": "n8n-nodes-base.splitInBatches",
"position": [
2100,
500
],
"parameters": {
"options": []
},
"typeVersion": 3
},
{
"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
},
{
"id": "bbf44181-0ea7-48b2-b89e-143d72460d27",
"name": "Get Source URLs Data",
"type": "n8n-nodes-base.httpRequest",
"position": [
2320,
500
],
"parameters": {
"url": "https:\/\/api.dataforseo.com\/v3\/on_page\/links",
"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": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application\/json"
}
]
}
},
"typeVersion": 4.20000000000000017763568394002504646778106689453125
},
{
"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](https:\/\/docs.n8n.io\/integrations\/builtin\/credentials\/httprequest\/). You can get your DataForSEO API credentials [here](https:\/\/app.dataforseo.com\/api-access). 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](https:\/\/docs.n8n.io\/integrations\/builtin\/credentials\/google\/oauth-generic\/). 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](https:\/\/customworkflows.ai\/work-with-us)."
},
"typeVersion": 1
},
{
"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.100000000000000088817841970012523233890533447265625
}
],
"active": false,
"pinData": [],
"settings": {
"executionOrder": "v1"
},
"versionId": "c6db2f12-2e4f-4f40-acf9-6664c9feb45e",
"connections": {
"If": {
"main": [
[
{
"node": "Get RAW Audit Data",
"type": "main",
"index": 0
}
],
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Check Task Status",
"type": "main",
"index": 0
}
]
]
},
"Wait1": {
"main": [
[
{
"node": "Map GSC Data to URL",
"type": "main",
"index": 0
}
]
]
},
"Set Fields": {
"main": [
[
{
"node": "Create Task",
"type": "main",
"index": 0
}
]
]
},
"Create Task": {
"main": [
[
{
"node": "Check Task Status",
"type": "main",
"index": 0
}
]
]
},
"Extract URLs": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Map URLs Data": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"Query GSC API": {
"main": [
[
{
"node": "Wait1",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[
{
"node": "Merge GSC Data with RAW Data",
"type": "main",
"index": 0
}
],
[
{
"node": "Query GSC API",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items1": {
"main": [
[
{
"node": "Build Report Structure",
"type": "main",
"index": 0
}
],
[
{
"node": "Get Source URLs Data",
"type": "main",
"index": 0
}
]
]
},
"Check Task Status": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"Extract 404 & 301": {
"main": [
[
{
"node": "Loop Over Items1",
"type": "main",
"index": 0
}
]
]
},
"Get RAW Audit Data": {
"main": [
[
{
"node": "Extract URLs",
"type": "main",
"index": 0
}
]
]
},
"Map GSC Data to URL": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Generate HTML Report": {
"main": [
[
{
"node": "Download Report",
"type": "main",
"index": 0
}
]
]
},
"Get Source URLs Data": {
"main": [
[
{
"node": "Map URLs Data",
"type": "main",
"index": 0
}
]
]
},
"Build Report Structure": {
"main": [
[
{
"node": "Generate HTML Report",
"type": "main",
"index": 0
}
]
]
},
"When clicking \u2018Start\u2019": {
"main": [
[
{
"node": "Set Fields",
"type": "main",
"index": 0
}
]
]
},
"Merge GSC Data with RAW Data": {
"main": [
[
{
"node": "Extract 404 & 301",
"type": "main",
"index": 0
}
]
]
}
}
}