Automated Content SEO Audit Report

工作流概述

这是一个包含21个节点的复杂工作流,主要用于自动化处理各种任务。

工作流源代码

下载
{
  "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.4
    },
    {
      "id": "57a66b27-a253-4543-9d44-cd3afdbc3946",
      "name": "When clicking ‘Start’",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        60,
        620
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "3e5e8162-2815-429f-b6e8-6ea6ea70cf18",
      "name": "Check Task Status",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        660,
        620
      ],
      "parameters": {
        "url": "=https://api.dataforseo.com/v3/on_page/summary/{{ $json.tasks[0].id }}",
        "options": {},
        "sendHeaders": true,
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "9ea481fe-8af6-43c2-881d-eb68f63b0424",
      "name": "Create Task",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        480,
        620
      ],
      "parameters": {
        "url": "https://api.dataforseo.com/v3/on_page/task_post",
        "method": "POST",
        "options": {},
        "jsonBody": "=[
  {
    \"target\": \"{{ $json.dfs_domain }}\",
    \"max_crawl_pages\": {{ $json.dfs_max_crawl_pages }},
    \"load_resources\": false,
    \"enable_javascript\": {{ $json.dfs_enable_javascript }},
    \"custom_js\": \"meta = {}; meta.url = document.URL; meta;\",
    \"tag\": \"{{ $json.dfs_domain + Math.floor(10000 + Math.random() * 90000) }}\"
  }
]",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "0a0e696a-29a7-4b34-8299-102c72544153",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        860,
        620
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "7e13429d-9ead-4ae5-8ed6-c5730b05927d",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.tasks[0].result[0].crawl_progress }}",
              "rightValue": "finished"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "a31db736-23e0-4db8-ab90-294cd87c9123",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        1060,
        680
      ],
      "webhookId": "f60d5346-5ddf-4819-a865-48e2d9e6103c",
      "parameters": {
        "unit": "minutes",
        "amount": 1
      },
      "typeVersion": 1.1
    },
    {
      "id": "8f95fd0b-e990-4c85-b21b-83d06d2121fe",
      "name": "Get RAW Audit Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1060,
        500
      ],
      "parameters": {
        "url": "https://api.dataforseo.com/v3/on_page/pages",
        "method": "POST",
        "options": {},
        "jsonBody": "=[
  {
    \"id\": \"{{ $json.tasks[0].id }}\",
    \"limit\": \"1000\"
  }
]",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "6cf221d9-c17e-4a5c-9c9a-c3176319df95",
      "name": "Extract URLs",
      "type": "n8n-nodes-base.code",
      "position": [
        1260,
        500
      ],
      "parameters": {
        "jsCode": "// Get input data from the previous node
const input = $input.all();

// Initialize an array to store the new items
const output = [];

// Loop through each input item
for (const item of input) {
    const tasks = item.json.tasks || [];
    for (const task of tasks) {
        const results = task.result || [];
        for (const result of results) {
            const items = result.items || [];
            for (const page of items) {
                // Only include URLs with status_code 200
                if (page.url && page.status_code === 200) {
                    output.push({ json: { url: page.url } });
                }
            }
        }
    }
}

// Return all URLs with status code 200 as separate items
return 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": "={{ 
  $('Set Fields').first().json.gsc_property_type === 'domain' 
    ? 'https://searchconsole.googleapis.com/webmasters/v3/sites/' + 
      'sc-domain:' + 
      $node[\"Loop Over Items\"].json.url.replace(/https?:\/\/(www\.)?([^\/]+).*/, '$2') + 
      '/searchAnalytics/query' 
    : 'https://searchconsole.googleapis.com/webmasters/v3/sites/' + 
      encodeURIComponent(
        $node[\"Loop Over Items\"].json.url.replace(/(https?:\/\/(?:www\.)?[^\/]+).*/, '$1')
      ) + 
      '/searchAnalytics/query' 
}}",
        "body": "={
  \"startDate\": \"{{ new Date(new Date().setDate(new Date().getDate() - 90)).toISOString().split('T')[0] }}\",
  \"endDate\": \"{{ new Date().toISOString().split('T')[0] }}\",
  \"dimensionFilterGroups\": [
    {
      \"filters\": [
        {
          \"dimension\": \"page\",
          \"operator\": \"equals\",
          \"expression\": \"{{ $node['Loop Over Items'].json.url }}\"
        }
      ]
    }
  ],
  \"aggregationType\": \"auto\",
  \"rowLimit\": 100
}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "raw",
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "rawContentType": "JSON",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "nodeCredentialType": "googleOAuth2Api"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "d9943a4b-7320-47ce-95fa-67eb28cabd26",
      "name": "Wait1",
      "type": "n8n-nodes-base.wait",
      "position": [
        1680,
        680
      ],
      "webhookId": "8b2109f4-1aca-4585-8261-7dfc4ca2f95e",
      "parameters": {
        "unit": "minutes",
        "amount": 1
      },
      "typeVersion": 1.1
    },
    {
      "id": "f2f7e975-1db1-4566-b674-396ccaa775f5",
      "name": "Map GSC Data to URL",
      "type": "n8n-nodes-base.set",
      "position": [
        1880,
        680
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "342ff66d-cdfc-46e8-9605-db588c913eb0",
              "name": "URL",
              "type": "string",
              "value": "={{ $('Loop Over Items').item.json.url }}"
            },
            {
              "id": "5c547efc-0514-4641-8f05-c24b965993ad",
              "name": "Clicks",
              "type": "string",
              "value": "={{ $('Query GSC API').item.json.rows[0].clicks }}"
            },
            {
              "id": "340c3ced-061d-49f0-911d-bd8b9e433a7d",
              "name": "Impressions",
              "type": "string",
              "value": "={{ $('Query GSC API').item.json.rows[0].impressions }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "4e42e1eb-4769-4e28-9f2f-3fb342baf971",
      "name": "Merge GSC Data with RAW Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1680,
        500
      ],
      "parameters": {
        "jsCode": "/*
 * Function node
 * Inputs: none (reads data from other nodes)
 * Output: ONE item whose .json is the enriched audit object
 */

// 1. ----  Get the raw audit JSON  ------------------------------------------
let rawAuditData = $node['Get RAW Audit Data'].json;   // first item of that node

// If that node delivered a JSON string, parse it:
if (typeof rawAuditData === 'string') {
	rawAuditData = JSON.parse(rawAuditData);
}

// 2. ----  Get the Google Search Console rows  ------------------------------
const gscItems = $items('Loop Over Items');            // all items from that node

// 3. ----  Build a fast lookup:  URL -> { clicks, impressions }  ------------
const gscLookup = {};
for (const { json } of gscItems) {
    const { URL, Clicks, Impressions } = json;
    if (URL) {
        gscLookup[URL] = {
            clicks: Clicks !== undefined ? Number(Clicks) || 0 : null,
            impressions: Impressions !== undefined ? Number(Impressions) || 0 : null,
        };
    }
}

// 4. ----  Enrich every page record with googleSearchConsoleData -------------
const itemsPath = (((rawAuditData.tasks || [])[0] || {}).result || [])[0]?.items || [];

for (const page of itemsPath) {
    const url = page.url;
    page.googleSearchConsoleData = gscLookup[url] || { clicks: null, impressions: null };
}

// 5. ----  Return ONE item with the updated audit data  ----------------------
return [
	{
		json: rawAuditData,   // <-- an actual object, so n8n is satisfied
	},
];"
      },
      "typeVersion": 2
    },
    {
      "id": "0b35fb68-6a0d-4eea-b29a-96550574c2b8",
      "name": "Build Report Structure",
      "type": "n8n-nodes-base.code",
      "position": [
        2100,
        320
      ],
      "parameters": {
        "jsCode": "/**
 * n8n – Function node
 * Input  : • One item whose `json` is the crawl + GSC data
 *          • All the items produced by the loop node “Loop Over Items1”
 * Output : ONE item whose `json` = { generatedAt, summary, issues, pages }
 *          – Unchanged shape, just extra `sources`[] on 404 / 301 records
 */

/* ────────────────────── helpers & constants ───────────────────── */
const CUR_YEAR          = new Date().getFullYear();
const YEAR_RX           = /20\d{2}/g;
const TWELVE_MONTHS_MS  = 1000 * 60 * 60 * 24 * 365.25;
const SIX_MONTHS_MS     = TWELVE_MONTHS_MS / 2;
const LARGE_HTML_LIMIT  = 2_000_000;

const ageInMs      = (s) => Date.now() - Date.parse(s);
const ensureBucket = (parent, key) => (parent[key] ??= []);
const normalizeUrl = (u) => (u || '').replace(/\/+$/, '');   // strip trailing “/”

/* ────────────────────── main data sets ───────────────────────── */
const root  = $node['Merge GSC Data with RAW Data'].json;
const pages = root.tasks?.[0]?.result?.[0]?.items ?? [];

/* link-source items from the loop node */
const sourceItems = $items('Loop Over Items1') ?? [];
const linkSourceMap = {};               // { normalisedTargetUrl : [ {linkFrom,type,text},… ] }

for (const itm of sourceItems) {
  const j   = itm.json || {};
  const tgt = normalizeUrl(j.URL);
  if (!tgt) continue;

  linkSourceMap[tgt] ??= [];
  for (const s of j.sources || []) {
    linkSourceMap[tgt].push({
      linkFrom: s.link_from,
      type    : s.type,
      text    : s.text,
    });
  }
}

/* ────────────────────── duplicate-meta look-ups ───────────────── */
const titleFreq = {};
const descFreq  = {};

for (const p of pages) {
  const t = p.meta?.title?.trim();
  const d = p.meta?.description?.trim();
  if (t) titleFreq[t] = (titleFreq[t] || 0) + 1;
  if (d) descFreq[d]  = (descFreq[d]  || 0) + 1;
}

/* ────────────────────── report skeleton ──────────────────────── */
const issues = {
  statusIssues:         {},
  contentQuality:       {},
  metadataSEO:          {},
  internalLinking:      {},
  underperformingContent: [],
};

const summary        = { pages: pages.length };
const pagesWithFlags = [];

/* ────────────────────── per-page loop ────────────────────────── */
for (const p of pages) {
  const url   = p.url;
  const norm  = normalizeUrl(url);
  const flags = [];

  const add = (sect, bucket, rec) => ensureBucket(issues[sect], bucket).push(rec);

  const isStatusOK = p.status_code === 200;

  /* 1 · 404 ---------------------------------------------------- */
  if (p.status_code === 404 || p.checks?.is_4xx_code) {
    flags.push('404');
    add('statusIssues', 'pages404', {
      url,
      sources: linkSourceMap[norm] ?? [],      // ← new
      todo  : 'Restore the page or 301-redirect it to a relevant URL.',
    });
  }

  /* 2 · 301 ---------------------------------------------------- */
  if (p.status_code === 301 || p.checks?.is_redirect) {
    flags.push('redirect_301');
    add('statusIssues', 'redirects301', {
      url,
      sources: linkSourceMap[norm] ?? [],      // ← new
      todo  : 'Update internal links so they point directly to the final URL (single-hop redirect).',
    });
  }

  /* 3 ­– 15 · all original checks (unchanged) ------------------ */
  /* Canonicalised */
  const canonicalised =
      (p.meta?.canonical && p.meta.canonical !== url) ||
      p.checks?.canonical_chain ||
      p.checks?.recursive_canonical;

  if (isStatusOK && canonicalised) {
    flags.push('canonicalised');
    add('statusIssues', 'canonicalised', {
      url,
      canonical: p.meta?.canonical,
      todo: `Verify that \"${p.meta?.canonical || '—'}\" is the correct canonical target and eliminate unintended duplicates.`,
    });
  }

  /* Outdated content (years + stale last-modified) */
  if (isStatusOK) {
    const titleYears = (p.meta?.title?.match(YEAR_RX) || []).filter((y) => Number(y) < CUR_YEAR);
    const descYears  = (p.meta?.description?.match(YEAR_RX) || []).filter((y) => Number(y) < CUR_YEAR);

    if (titleYears.length) {
      flags.push('outdated_year_title');
      add('contentQuality', 'outdatedMetaYear', {
        url,
        field    : 'title',
        years    : titleYears.join(','),
        original : p.meta?.title,
        todo     : `Title contains old year → ${titleYears.join(', ')}. Update to ${CUR_YEAR} or remove dates.`,
      });
    }
    if (descYears.length) {
      flags.push('outdated_year_description');
      add('contentQuality', 'outdatedMetaYear', {
        url,
        field    : 'description',
        years    : descYears.join(','),
        original : p.meta?.description,
        todo     : `Meta description contains old year → ${descYears.join(', ')}. Update to ${CUR_YEAR} or remove dates.`,
      });
    }

    const lm = p.last_modified ??
               p.meta?.social_media_tags?.['og:updated_time'] ?? null;

    if (lm && ageInMs(lm) > TWELVE_MONTHS_MS) {
      flags.push('stale_last_modified');
      add('contentQuality', 'staleLastModified', {
        url,
        lastModified: lm,
        todo        : 'Page not updated for 12+ months — refresh content.',
      });
    }
  }

  /* Thin content */
  if (isStatusOK) {
    const wc = p.meta?.content?.plain_text_word_count || 0;
    if (p.click_depth !== 0 && wc >= 1 && wc <= 1500) {
      flags.push('thin_content');
      add('contentQuality', 'thinContent', {
        url,
        words: wc,
        todo : 'Expand the piece beyond 1 500 words with valuable, unique information.',
      });
    }
  }

  /* Excessive click depth */
  if (isStatusOK && (p.click_depth || 0) > 4) {
    flags.push('excessive_click_depth');
    add('internalLinking', 'excessiveClickDepth', {
      url,
      depth: p.click_depth,
      todo : 'Surface this URL within ≤4 clicks via navigation or contextual links.',
    });
  }

  /* Large HTML */
  if (isStatusOK && ((p.size || 0) > LARGE_HTML_LIMIT || (p.total_dom_size || 0) > LARGE_HTML_LIMIT)) {
    flags.push('large_html');
    add('contentQuality', 'largeHTML', {
      url,
      size    : p.size,
      totalDom: p.total_dom_size,
      todo    : 'Reduce HTML payload (remove unused markup/JS, paginate, or lazy-load where possible).',
    });
  }

  /* Title length */
  if (isStatusOK && (p.meta?.title_length < 40 || p.meta?.title_length > 60)) {
    flags.push('title_length');
    add('metadataSEO', 'titleLength', {
      url,
      length: p.meta?.title_length,
      todo  : `Write a meta title 40-60 characters long (currently ${p.meta?.title_length || 0}).`,
    });
  }

  /* Description length */
  if (isStatusOK) {
    const dl = p.meta?.description_length || 0;
    if (dl > 0 && (dl < 70 || dl > 155)) {
      flags.push('description_length');
      add('metadataSEO', 'descriptionLength', {
        url,
        length: dl,
        todo  : `Write a meta description 70-155 characters long (currently ${dl}).`,
      });
    }
  }

  /* Missing / duplicate meta */
  if (isStatusOK) {
    if (p.checks?.no_title) {
      flags.push('missing_title');
      add('metadataSEO', 'missingTitle', { url, todo: 'Add a unique SEO title 40-60 characters long.' });
    }
    if (p.checks?.no_description) {
      flags.push('missing_description');
      add('metadataSEO', 'missingDescription', { url, todo: 'Add a unique meta description 70-155 characters long.' });
    }
    if (titleFreq[p.meta?.title?.trim()] > 1) {
      flags.push('duplicate_title');
      add('metadataSEO', 'duplicateTitle', { url, title: p.meta?.title, todo: 'Differentiate this title to avoid keyword cannibalisation.' });
    }
    if (p.meta?.description && descFreq[p.meta.description.trim()] > 1) {
      flags.push('duplicate_description');
      add('metadataSEO', 'duplicateDescription', { url, description: p.meta?.description, todo: 'Rewrite the meta description so each page is unique.' });
    }
  }

  /* H1 issues */
  if (isStatusOK) {
    const h1s = p.meta?.htags?.h1 ?? [];
    if (h1s.length !== 1) {
      flags.push('h1_issue');
      add('metadataSEO', 'h1Issues', { url, h1Count: h1s.length, todo: 'Ensure exactly one H1 tag per page that reflects the main topic.' });
    }
  }

  /* Readability */
  if (isStatusOK) {
    const fk = p.meta?.content?.flesch_kincaid_readability_index ?? 100;
    if (fk < 55) {
      flags.push('low_readability');
      add('contentQuality', 'readability', { url, score: fk, todo: `Simplify language, shorten sentences, and use lists to lift F-K score > 55 (currently ${fk.toFixed(2)}).` });
    }
  }

  /* Orphan pages */
  if (isStatusOK && p.checks?.is_orphan_page) {
    flags.push('orphan_page');
    add('internalLinking', 'orphanPages', { url, todo: 'Add at least one crawlable internal link pointing to this URL.' });
  }

  /* Low internal links */
  if (isStatusOK && (p.meta?.internal_links_count || 0) < 3) {
    flags.push('low_internal_links');
    add('internalLinking', 'lowInternalLinks', { url, links: p.meta?.inbound_links_count, todo: 'Add three or more relevant internal links to strengthen topical signals.' });
  }

  /* Under-performing content */
  if (isStatusOK) {
    const clicks      = p.googleSearchConsoleData?.clicks ?? null;
    const impressions = p.googleSearchConsoleData?.impressions ?? null;
    const lm          = p.last_modified ?? p.meta?.social_media_tags?.['og:updated_time'] ?? null;

    if (clicks !== null && clicks < 50 && (lm === null || ageInMs(lm) > SIX_MONTHS_MS)) {
      flags.push('underperforming');
      issues.underperformingContent.push({
        url,
        clicks,
        impressions,
        lastModified: lm,
        todo: `Only ${clicks} clicks in the last 90 days — refresh content, improve targeting, or consider pruning.`,
      });
    }
  }

  /* page-level flags record */
  pagesWithFlags.push({
    url,
    flags,
    clicks     : p.googleSearchConsoleData?.clicks,
    impressions: p.googleSearchConsoleData?.impressions,
  });
}

/* ────────────────────── executive summary ────────────────────── */
const count = (sect, bucket) => issues[sect]?.[bucket]?.length || 0;

summary.issues = {
  '404'                 : count('statusIssues', 'pages404'),
  redirects             : count('statusIssues', 'redirects301'),
  canonicalised         : count('statusIssues', 'canonicalised'),
  outdated              : count('contentQuality', 'outdatedMetaYear') +
                           count('contentQuality', 'staleLastModified'),
  thin                  : count('contentQuality', 'thinContent'),
  excessiveClickDepth   : count('internalLinking', 'excessiveClickDepth'),
  largeHTML             : count('contentQuality', 'largeHTML'),
  titleLen              : count('metadataSEO', 'titleLength'),
  descriptionLen        : count('metadataSEO', 'descriptionLength'),
  missingOrDuplicateMeta:
      count('metadataSEO', 'missingTitle') +
      count('metadataSEO', 'missingDescription') +
      count('metadataSEO', 'duplicateTitle') +
      count('metadataSEO', 'duplicateDescription'),
  h1Issues              : count('metadataSEO', 'h1Issues'),
  readability           : count('contentQuality', 'readability'),
  orphan                : count('internalLinking', 'orphanPages'),
  lowInternalLinks      : count('internalLinking', 'lowInternalLinks'),
  underperforming       : issues.underperformingContent.length,
};

/* ────────────────────── final report ─────────────────────────── */
return [{
  json: {
    generatedAt: new Date().toISOString(),
    summary,
    issues,
    pages: pagesWithFlags,
  },
}];"
      },
      "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
const auditData = $('Build Report Structure').item.json;
const websiteDomain = $('Set Fields').first().json.dfs_domain;
const companyName = $('Set Fields').first().json.company_name;
const companyWebsite = $('Set Fields').first().json.company_website;
const companyLogoUrl = $('Set Fields').first().json.company_logo_url;
const primaryColor = $('Set Fields').first().json.brand_primary_color;
const secondaryColor = $('Set Fields').first().json.brand_secondary_color;

// Format date nicely
const formattedDate = new Date(auditData.generatedAt).toLocaleDateString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
});

// Calculate total issues
const totalIssues = Object.values(auditData.summary.issues).reduce((sum, count) => sum + count, 0);

// Define issue gravity weights for health score calculation
const issueGravity = {
  // Content Quality
  outdated: 2, // Medium
  thin: 3, // High
  readability: 1, // Low
  largeHTML: 2, // Medium
  // Technical SEO
  '404': 3, // High
  redirects: 2, // Medium
  canonicalised: 3, // High
  // On-Page SEO
  titleLen: 1, // Low
  descriptionLen: 1, // Low
  missingOrDuplicateMeta: 1, // Low
  h1Issues: 3, // High
  // Internal Linking
  excessiveClickDepth: 3, // High
  orphan: 3, // High
  lowInternalLinks: 3, // High
  // Performance
  underperforming: 3 // High
};

// Calculate health score based on issue gravity
function calculateHealthScore(pages, issues) {
  // Calculate weighted sum of issues
  let weightedIssues = 0;
  let maxPossibleWeightedIssues = 0;
  
  // Process each issue type with its gravity weight
  for (const [issueType, count] of Object.entries(auditData.summary.issues)) {
    const gravity = issueGravity[issueType] || 1; // Default to Low if not defined
    weightedIssues += count * gravity;
    
    // Assume worst case: all pages have this issue
    maxPossibleWeightedIssues += pages * gravity;
  }
  
  // Cap the maximum penalty to avoid too severe scores with many pages
  const maxPenalty = Math.min(pages * 5, 100);
  
  // Calculate score: start at 100 and subtract weighted penalty
  const weightedPenalty = Math.min(maxPenalty, (weightedIssues / Math.max(1, pages)) * 2);
  const score = 100 - weightedPenalty;
  
  return Math.max(0, Math.round(score));
}

// Get health score color based on value
function getHealthScoreColor(score) {
  if (score >= 80) return '#4caf50'; // Green
  if (score >= 60) return '#ff9800'; // Orange
  return '#f44336'; // Red
}

// Get top recommendations
function getTopRecommendations(audit) {
  const recommendations = [];
  const priorityMap = {
    3: \"high\",     // High gravity issues
    2: \"medium\",   // Medium gravity issues
    1: \"low\"       // Low gravity issues
  };
  
  // Check for high gravity issues first
  if ((audit.issues.contentQuality.thinContent || []).length > 0) {
    recommendations.push({
      text: \"Expand thin content pages to improve topical depth and authority\",
      priority: priorityMap[issueGravity.thin] || \"high\"
    });
  }
  
  if ((audit.issues.statusIssues.pages404 || []).length > 0) {
    recommendations.push({
      text: \"Fix 404 errors by restoring pages or implementing proper redirects\",
      priority: priorityMap[issueGravity['404']] || \"high\"
    });
  }
  
  if ((audit.issues.metadataSEO.h1Issues || []).length > 0) {
    recommendations.push({
      text: \"Fix H1 tag issues to improve on-page SEO and content hierarchy\",
      priority: priorityMap[issueGravity.h1Issues] || \"high\"
    });
  }
  
  if ((audit.issues.internalLinking.orphanPages || []).length > 0) {
    recommendations.push({
      text: \"Create internal links to orphan pages to improve crawlability\",
      priority: priorityMap[issueGravity.orphan] || \"high\"
    });
  }
  
  if ((audit.issues.underperformingContent || []).length > 0) {
    recommendations.push({
      text: \"Optimize underperforming pages to improve search visibility\",
      priority: priorityMap[issueGravity.underperforming] || \"high\"
    });
  }
  
  if ((audit.issues.statusIssues.canonicalised || []).length > 0) {
    recommendations.push({
      text: \"Fix canonicalization issues to consolidate ranking signals\",
      priority: priorityMap[issueGravity.canonicalised] || \"high\"
    });
  }
  
  // Medium gravity issues
  if ((audit.issues.contentQuality.staleLastModified || []).length > 0) {
    recommendations.push({
      text: \"Update stale content with fresh information and current year references\",
      priority: priorityMap[issueGravity.outdated] || \"medium\"
    });
  }
  
  if ((audit.issues.statusIssues.redirects301 || []).length > 0) {
    recommendations.push({
      text: \"Update internal links to point directly to final URLs instead of through redirects\",
      priority: priorityMap[issueGravity.redirects] || \"medium\"
    });
  }
  
  if ((audit.issues.contentQuality.largeHTML || []).length > 0) {
    recommendations.push({
      text: \"Reduce HTML size for better page performance and loading speed\",
      priority: priorityMap[issueGravity.largeHTML] || \"medium\"
    });
  }
  
  // Low gravity issues
  if ((audit.issues.metadataSEO.missingDescription || []).length > 0) {
    recommendations.push({
      text: \"Add missing meta descriptions to improve click-through rates\",
      priority: priorityMap[issueGravity.missingOrDuplicateMeta] || \"low\"
    });
  }
  
  if ((audit.issues.contentQuality.readability || []).length > 0) {
    recommendations.push({
      text: \"Improve content readability to enhance user experience\",
      priority: priorityMap[issueGravity.readability] || \"low\"
    });
  }
  
  // Fallback if not enough recommendations
  if (recommendations.length < 3) {
    recommendations.push({
      text: \"Implement a regular content audit schedule to maintain freshness\",
      priority: \"low\"
    });
  }
  
  // Return top 5 recommendations, prioritizing high gravity issues first
  return recommendations
    .sort((a, b) => {
      const priorityOrder = { \"high\": 0, \"medium\": 1, \"low\": 2 };
      return priorityOrder[a.priority] - priorityOrder[b.priority];
    })
    .slice(0, 5);
}

// Format flag names for display
function formatFlagName(flag) {
  return flag
    .split('_')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

// Utility to lighten a color
function lightenColor(hex, percent) {
  hex = hex.replace('#', '');
  let r = parseInt(hex.substring(0, 2), 16);
  let g = parseInt(hex.substring(2, 4), 16);
  let b = parseInt(hex.substring(4, 6), 16);
  r = Math.min(255, Math.round(r + (255 - r) * (percent / 100)));
  g = Math.min(255, Math.round(g + (255 - g) * (percent / 100)));
  b = Math.min(255, Math.round(b + (255 - b) * (percent / 100)));
  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}

// Utility to darken a color
function darkenColor(hex, percent) {
  hex = hex.replace('#', '');
  let r = parseInt(hex.substring(0, 2), 16);
  let g = parseInt(hex.substring(2, 4), 16);
  let b = parseInt(hex.substring(4, 6), 16);
  r = Math.max(0, Math.round(r * (1 - percent / 100)));
  g = Math.max(0, Math.round(g * (1 - percent / 100)));
  b = Math.max(0, Math.round(b * (1 - percent / 100)));
  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}

// Helper function to render a table section or \"No issues found\" message
function renderTableSection(items, columns) {
  if (!items || items.length === 0) {
    return `<p class=\"section-empty\">No issues found.</p>`;
  }
  
  const showInitial = 10; // Number of rows to show initially
  const hasMoreItems = items.length > showInitial;
  const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;
  const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];
  
  return `
    <table class=\"paginated-table\">
      <thead>
        <tr>
          ${columns.map(col => `<th>${col.header}</th>`).join('')}
        </tr>
      </thead>
      <tbody class=\"initial-rows\">
        ${initialItems.map(item => `
          <tr>
            ${columns.map(col => `<td class=\"${col.class || ''}\">${col.render(item)}</td>`).join('')}
          </tr>
        `).join('')}
      </tbody>
      ${hasMoreItems ? `
        <tbody class=\"hidden-rows\" style=\"display: none;\">
          ${hiddenItems.map(item => `
            <tr>
              ${columns.map(col => `<td class=\"${col.class || ''}\">${col.render(item)}</td>`).join('')}
            </tr>
          `).join('')}
        </tbody>
      ` : ''}
    </table>
    ${hasMoreItems ? `
      <div class=\"table-pagination\">
        <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>
      </div>
    ` : ''}
  `;
}

// Helper function to render source links for 404 and 301 pages
function renderSourceLinks(sources) {
  if (!sources || sources.length === 0) {
    return '<p class=\"no-sources\">No source links found.</p>';
  }
  
  return `
    <div class=\"source-links\">
      <table class=\"source-links-table\">
        <thead>
          <tr>
            <th>Source URL</th>
            <th>Type</th>
            <th>Anchor Text</th>
          </tr>
        </thead>
        <tbody>
          ${sources.map(source => `
            <tr>
              <td class=\"url-cell\"><a href=\"${source.linkFrom}\" target=\"_blank\">${source.linkFrom}</a></td>
              <td>${source.type || 'N/A'}</td>
              <td>${source.text || 'N/A'}</td>
            </tr>
          `).join('')}
        </tbody>
      </table>
    </div>
  `;
}

// Return a single item with the HTML content
return [{
  html: `<!DOCTYPE html>
<html lang=\"en\">
<head>
  <meta charset=\"UTF-8\">
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
  <title>Content Audit Report for ${websiteDomain} | ${companyName}</title>
  <style>
    :root {
      --primary-color: ${primaryColor};
      --secondary-color: ${secondaryColor};
      --primary-light: ${lightenColor(primaryColor, 85)};
      --secondary-light: ${lightenColor(secondaryColor, 85)};
      --primary-dark: ${darkenColor(primaryColor, 20)};
      --text-color: #333;
      --light-gray: #f5f5f5;
      --medium-gray: #e0e0e0;
      --dark-gray: #757575;
      --success-color: #4caf50;
      --warning-color: #ff9800;
      --danger-color: #f44336;
    }
    
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    
    body {
      font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
      line-height: 1.6;
      color: var(--text-color);
      background-color: #fff;
    }
    
    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 0 20px;
    }
    
    header {
      background-color: var(--primary-color);
      color: white;
      padding: 30px 0;
      margin-bottom: 40px;
    }
    
    .header-content {
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    
    .logo-container {
      display: flex;
      align-items: center;
    }
    
    .logo {
      max-height: 60px;
      margin-right: 20px;
    }
    
    .report-info {
      text-align: right;
    }
    
    h1 {
      font-size: 1.8rem;
      margin-bottom: 0px;
      color: white;
    }
    
    h2 {
      font-size: 1.8rem;
      margin: 40px 0 20px;
      color: var(--primary-color);
      border-bottom: 2px solid var(--primary-light);
      padding-bottom: 10px;
    }
    
    h3 {
      font-size: 1.4rem;
      margin: 30px 0 15px;
      color: var(--primary-dark);
    }
    
    h4 {
      font-size: 1.2rem;
      margin: 20px 0 10px;
      color: var(--secondary-color);
    }
    
    p {
      margin-bottom: 15px;
    }
    
    .summary-cards {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
      gap: 20px;
      margin: 30px 0;
    }
    
    .card {
      background-color: white;
      border-radius: 8px;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      padding: 20px;
      transition: transform 0.3s ease;
    }
    
    .card:hover {
      transform: translateY(-5px);
    }
    
    .card-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 15px;
    }
    
    .card-title {
      font-size: 1.2rem;
      font-weight: 600;
      color: var(--primary-color);
    }
    
    .card-value {
      font-size: 2.5rem;
      font-weight: 700;
      color: var(--secondary-color);
    }
    
    .issues-summary {
      display: flex;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 15px;
      margin: 30px 0;
    }
    
    .issue-category {
      flex: 1;
      min-width: 250px;
      background-color: var(--light-gray);
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
    }
    
    .issue-category h3 {
      color: var(--primary-color);
      margin-top: 0;
      border-bottom: 1px solid var(--medium-gray);
      padding-bottom: 10px;
    }
    
    .issue-item {
      display: flex;
      justify-content: space-between;
      padding: 8px 0;
      border-bottom: 1px solid var(--medium-gray);
    }
    
    .issue-item:last-child {
      border-bottom: none;
    }
    
    .issue-name {
      color: var(--text-color);
    }
    
    .issue-count {
      font-weight: 600;
      color: var(--secondary-color);
    }
    
    table {
      width: 100%;
      border-collapse: collapse;
      margin: 20px 0 40px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
    
    th {
      background-color: var(--primary-color);
      color: white;
      text-align: left;
      padding: 12px 15px;
    }
    
    tr:nth-child(even) {
      background-color: var(--light-gray);
    }
    
    td {
      padding: 10px 15px;
      border-bottom: 1px solid var(--medium-gray);
    }
    
    .url-cell {
      max-width: 300px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    
    .url-cell a {
      color: var(--primary-color);
      text-decoration: none;
    }
    
    .url-cell a:hover {
      text-decoration: underline;
    }
    
    .todo-cell {
      max-width: 400px;
    }
    
    .flag {
      display: inline-block;
      padding: 3px 8px;
      border-radius: 4px;
      margin: 2px;
      font-size: 0.8rem;
      background-color: var(--primary-light);
      color: var(--primary-dark);
    }
    
    .pages-table {
      margin-top: 30px;
    }
    
    .pages-table th {
      position: sticky;
      top: 0;
    }
    
    footer {
      margin-top: 60px;
      padding: 30px 0;
      background-color: var(--primary-light);
      color: var(--primary-dark);
      text-align: center;
    }
    
    .footer-content {
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    
    .company-info {
      margin-bottom: 20px;
    }
    
    .company-website {
      color: var(--primary-color);
      text-decoration: none;
      font-weight: 600;
    }
    
    .company-website:hover {
      text-decoration: underline;
    }
    
    .date-generated {
      font-style: italic;
      color: var(--dark-gray);
    }
    
    .progress-bar-container {
      width: 100%;
      background-color: var(--light-gray);
      border-radius: 10px;
      margin: 10px 0;
      overflow: hidden;
    }
    
    .progress-bar {
      height: 10px;
      background-color: var(--secondary-color);
      border-radius: 10px;
    }
    
    .recommendations {
      background-color: var(--secondary-light);
      border-left: 4px solid var(--secondary-color);
      padding: 15px;
      margin: 20px 0;
      border-radius: 0 4px 4px 0;
    }
    
    .recommendations h4 {
      color: var(--secondary-color);
      margin-top: 0;
    }
    
    .recommendations ul {
      margin-left: 20px;
    }
    
    .recommendations li {
      margin-bottom: 8px;
    }
    
    .priority-tag {
      display: inline-block;
      padding: 3px 8px;
      border-radius: 4px;
      margin-left: 8px;
      font-size: 0.8rem;
      font-weight: 600;
    }
    
    .high {
      background-color: rgba(244, 67, 54, 0.1);
      color: var(--danger-color);
    }
    
    .medium {
      background-color: rgba(255, 152, 0, 0.1);
      color: var(--warning-color);
    }
    
    .low {
      background-color: rgba(76, 175, 80, 0.1);
      color: var(--success-color);
    }
    
    .section-empty {
      font-style: italic;
      color: var(--dark-gray);
      padding: 15px;
      background-color: var(--light-gray);
      border-radius: 4px;
      text-align: center;
    }
    
    .source-links {
      margin-top: 10px;
      margin-bottom: 20px;
      padding: 10px;
      background-color: var(--light-gray);
      border-radius: 4px;
      border-left: 3px solid var(--secondary-color);
    }
    
    .source-links h4 {
      margin-top: 0;
      margin-bottom: 10px;
      color: var(--secondary-color);
      font-size: 1rem;
    }
    
    .source-links-table {
      margin: 0;
      box-shadow: none;
    }
    
    .source-links-table th {
      background-color: var(--secondary-color);
      font-size: 0.9rem;
      padding: 8px 10px;
    }
    
    .source-links-table td {
      font-size: 0.9rem;
      padding: 6px 10px;
    }
    
    .no-sources {
      font-style: italic;
      color: var(--dark-gray);
      margin: 5px 0;
    }
    
    .toggle-sources {
      background-color: var(--secondary-light);
      color: var(--secondary-color);
      border: 1px solid var(--secondary-color);
      border-radius: 4px;
      padding: 5px 10px;
      font-size: 0.8rem;
      cursor: pointer;
      margin-top: 5px;
      transition: background-color 0.3s;
    }
    
    .toggle-sources:hover {
      background-color: var(--secondary-color);
      color: white;
    }
    
    .sources-container {
      margin-top: 10px;
    }
    
    .show-more-button {
      background-color: var(--primary-color);
      color: white;
      border: none;
      border-radius: 4px;
      padding: 8px 16px;
      font-size: 0.9rem;
      font-weight: 600;
      cursor: pointer;
      margin: 10px auto;
      display: block;
      transition: all 0.3s ease;
      box-shadow: 0 2px 5px rgba(0,0,0,0.1);
    }
    
    .show-more-button:hover {
      background-color: var(--primary-dark);
      box-shadow: 0 3px 7px rgba(0,0,0,0.2);
      transform: translateY(-2px);
    }
    
    .table-pagination {
      text-align: center;
      margin-top: -20px;
      margin-bottom: 30px;
    }
    
    @media print {
      body {
        font-size: 12pt;
      }
      
      .container {
        width: 100%;
        max-width: none;
        padding: 0;
      }
      
      header {
        padding: 15px 0;
      }
      
      h1 {
        font-size: 20pt;
      }
      
      h2 {
        font-size: 18pt;
        margin-top: 20px;
      }
      
      h3 {
        font-size: 14pt;
      }
      
      .card:hover {
        transform: none;
      }
      
      table {
        page-break-inside: avoid;
      }
      
      tr {
        page-break-inside: avoid;
      }
      
      .no-print {
        display: none;
      }
      
      @page {
        margin: 1.5cm;
      }
    }
  </style>
  <script>
    // JavaScript to toggle source links visibility
    document.addEventListener('DOMContentLoaded', function() {
      document.querySelectorAll('.toggle-sources').forEach(button => {
        button.addEventListener('click', function() {
          const container = this.nextElementSibling;
          if (container.style.display === 'none' || !container.style.display) {
            container.style.display = 'block';
            this.textContent = 'Hide Source Links';
          } else {
            container.style.display = 'none';
            this.textContent = 'Show Source Links';
          }
        });
      });
    });
    
    // JavaScript to toggle table rows visibility
    function toggleRows(button) {
      const table = button.closest('.table-pagination').previousElementSibling;
      const hiddenRows = table.querySelector('.hidden-rows');
      const totalRows = hiddenRows.querySelectorAll('tr').length + table.querySelector('.initial-rows').querySelectorAll('tr').length;
      
      if (hiddenRows.style.display === 'none' || !hiddenRows.style.display) {
        hiddenRows.style.display = 'table-row-group';
        button.textContent = 'Show Less';
      } else {
        hiddenRows.style.display = 'none';
        button.textContent = 'Show All (' + totalRows + ' items)';
      }
    }
  </script>
</head>
<body>
  <header>
    <div class=\"container\">
      <div class=\"header-content\">
        <div class=\"logo-container\">
          <img src=\"${companyLogoUrl}\" alt=\"${companyName} Logo\" class=\"logo\">
          <div>
            <h1>Content Audit Report</h1>
            <p>for ${websiteDomain}</p>
          </div>
        </div>
        <div class=\"report-info\">
          <p>Generated on: ${formattedDate}</p>
          <p>By: ${companyName}</p>
        </div>
      </div>
    </div>
  </header>

  <main class=\"container\">
    <section id=\"executive-summary\">
      <h2>Executive Summary</h2>
      <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>
      
      <div class=\"summary-cards\">
        <div class=\"card\">
          <div class=\"card-header\">
            <span class=\"card-title\">Pages Analyzed</span>
          </div>
          <div class=\"card-value\">${auditData.summary.pages}</div>
        </div>
        
        <div class=\"card\">
          <div class=\"card-header\">
            <span class=\"card-title\">Total Issues</span>
          </div>
          <div class=\"card-value\">${totalIssues}</div>
        </div>
        
        <div class=\"card\">
          <div class=\"card-header\">
            <span class=\"card-title\">Health Score</span>
          </div>
          <div class=\"card-value\" style=\"color: ${getHealthScoreColor(calculateHealthScore(auditData.summary.pages, totalIssues))};\">${calculateHealthScore(auditData.summary.pages, totalIssues)}%</div>
          <div class=\"progress-bar-container\">
            <div class=\"progress-bar\" style=\"width: ${calculateHealthScore(auditData.summary.pages, totalIssues)}%\"></div>
          </div>
        </div>
      </div>
      
      <div class=\"recommendations\">
        <h4>Key Recommendations</h4>
        <ul>
          ${getTopRecommendations(auditData).map(rec => `<li>${rec.text} <span class=\"priority-tag ${rec.priority}\">${rec.priority}</span></li>`).join('')}
        </ul>
      </div>
    </section>

    <section id=\"issues-breakdown\">
      <h2>Issues Breakdown</h2>
      
      <div class=\"issues-summary\">
        <div class=\"issue-category\">
          <h3>Content Quality</h3>
          <div class=\"issues-list\">
            <div class=\"issue-item\">
              <span class=\"issue-name\">Outdated Content</span>
              <span class=\"issue-count\">${auditData.summary.issues.outdated}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Thin Content</span>
              <span class=\"issue-count\">${auditData.summary.issues.thin}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Readability Issues</span>
              <span class=\"issue-count\">${auditData.summary.issues.readability}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Large HTML</span>
              <span class=\"issue-count\">${auditData.summary.issues.largeHTML}</span>
            </div>
          </div>
        </div>
        
        <div class=\"issue-category\">
          <h3>Technical SEO</h3>
          <div class=\"issues-list\">
            <div class=\"issue-item\">
              <span class=\"issue-name\">404 Errors</span>
              <span class=\"issue-count\">${auditData.summary.issues['404']}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Redirects</span>
              <span class=\"issue-count\">${auditData.summary.issues.redirects}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Canonicalization Issues</span>
              <span class=\"issue-count\">${auditData.summary.issues.canonicalised}</span>
            </div>
          </div>
        </div>
        
        <div class=\"issue-category\">
          <h3>On-Page SEO</h3>
          <div class=\"issues-list\">
            <div class=\"issue-item\">
              <span class=\"issue-name\">Title Length Issues</span>
              <span class=\"issue-count\">${auditData.summary.issues.titleLen}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Description Issues</span>
              <span class=\"issue-count\">${auditData.summary.issues.descriptionLen}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Missing/Duplicate Meta</span>
              <span class=\"issue-count\">${auditData.summary.issues.missingOrDuplicateMeta}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">H1 Issues</span>
              <span class=\"issue-count\">${auditData.summary.issues.h1Issues}</span>
            </div>
          </div>
        </div>
        
        <div class=\"issue-category\">
          <h3>Internal Linking</h3>
          <div class=\"issues-list\">
            <div class=\"issue-item\">
              <span class=\"issue-name\">Excessive Click Depth</span>
              <span class=\"issue-count\">${auditData.summary.issues.excessiveClickDepth}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Orphan Pages</span>
              <span class=\"issue-count\">${auditData.summary.issues.orphan}</span>
            </div>
            <div class=\"issue-item\">
              <span class=\"issue-name\">Low Internal Links</span>
              <span class=\"issue-count\">${auditData.summary.issues.lowInternalLinks}</span>
            </div>
          </div>
        </div>
        
        <div class=\"issue-category\">
          <h3>Performance</h3>
          <div class=\"issues-list\">
            <div class=\"issue-item\">
              <span class=\"issue-name\">Underperforming Pages</span>
              <span class=\"issue-count\">${auditData.summary.issues.underperforming}</span>
            </div>
          </div>
        </div>
      </div>
    </section>

    <!-- Status Issues Section -->
    <section id=\"status-issues\">
      <h2>Status Issues</h2>
      
      <h3>404 Errors (${(auditData.issues.statusIssues.pages404 || []).length})</h3>
      ${(auditData.issues.statusIssues.pages404 || []).length === 0 ? 
        `<p class=\"section-empty\">No issues found.</p>` : 
        (() => {
          const items = auditData.issues.statusIssues.pages404 || [];
          const showInitial = 10; // Number of rows to show initially
          const hasMoreItems = items.length > showInitial;
          const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;
          const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];
          
          return `
            <table class=\"paginated-table\">
              <thead>
                <tr>
                  <th>URL</th>
                  <th>Source Links</th>
                  <th>Recommendation</th>
                </tr>
              </thead>
              <tbody class=\"initial-rows\">
                ${initialItems.map(item => `
                  <tr>
                    <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>
                    <td>
                      ${item.sources && item.sources.length > 0 ? 
                        `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>
                        <div class=\"sources-container\" style=\"display: none;\">
                          ${renderSourceLinks(item.sources)}
                        </div>` : 
                        `<span class=\"no-sources\">No source links found</span>`
                      }
                    </td>
                    <td class=\"todo-cell\">${item.todo}</td>
                  </tr>
                `).join('')}
              </tbody>
              ${hasMoreItems ? `
                <tbody class=\"hidden-rows\" style=\"display: none;\">
                  ${hiddenItems.map(item => `
                    <tr>
                      <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>
                      <td>
                        ${item.sources && item.sources.length > 0 ? 
                          `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>
                          <div class=\"sources-container\" style=\"display: none;\">
                            ${renderSourceLinks(item.sources)}
                          </div>` : 
                          `<span class=\"no-sources\">No source links found</span>`
                        }
                      </td>
                      <td class=\"todo-cell\">${item.todo}</td>
                    </tr>
                  `).join('')}
                </tbody>
              ` : ''}
            </table>
            ${hasMoreItems ? `
              <div class=\"table-pagination\">
                <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>
              </div>
            ` : ''}
          `;
        })()
      }
      
      <h3>301 Redirects (${(auditData.issues.statusIssues.redirects301 || []).length})</h3>
      ${(auditData.issues.statusIssues.redirects301 || []).length === 0 ? 
        `<p class=\"section-empty\">No issues found.</p>` : 
        (() => {
          const items = auditData.issues.statusIssues.redirects301 || [];
          const showInitial = 10; // Number of rows to show initially
          const hasMoreItems = items.length > showInitial;
          const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;
          const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];
          
          return `
            <table class=\"paginated-table\">
              <thead>
                <tr>
                  <th>URL</th>
                  <th>Source Links</th>
                  <th>Recommendation</th>
                </tr>
              </thead>
              <tbody class=\"initial-rows\">
                ${initialItems.map(item => `
                  <tr>
                    <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>
                    <td>
                      ${item.sources && item.sources.length > 0 ? 
                        `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>
                        <div class=\"sources-container\" style=\"display: none;\">
                          ${renderSourceLinks(item.sources)}
                        </div>` : 
                        `<span class=\"no-sources\">No source links found</span>`
                      }
                    </td>
                    <td class=\"todo-cell\">${item.todo}</td>
                  </tr>
                `).join('')}
              </tbody>
              ${hasMoreItems ? `
                <tbody class=\"hidden-rows\" style=\"display: none;\">
                  ${hiddenItems.map(item => `
                    <tr>
                      <td class=\"url-cell\"><a href=\"${item.url}\" target=\"_blank\">${item.url}</a></td>
                      <td>
                        ${item.sources && item.sources.length > 0 ? 
                          `<button class=\"toggle-sources\">Show Source Links (${item.sources.length})</button>
                          <div class=\"sources-container\" style=\"display: none;\">
                            ${renderSourceLinks(item.sources)}
                          </div>` : 
                          `<span class=\"no-sources\">No source links found</span>`
                        }
                      </td>
                      <td class=\"todo-cell\">${item.todo}</td>
                    </tr>
                  `).join('')}
                </tbody>
              ` : ''}
            </table>
            ${hasMoreItems ? `
              <div class=\"table-pagination\">
                <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>
              </div>
            ` : ''}
          `;
        })()
      }
      
      <h3>Canonicalization Issues (${(auditData.issues.statusIssues.canonicalised || []).length})</h3>
      ${renderTableSection(auditData.issues.statusIssues.canonicalised, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Canonical URL', render: item => item.canonical || '—' },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
    </section>

    <!-- Content Quality Issues Section -->
    <section id=\"content-quality-issues\">
      <h2>Content Quality Issues</h3>
      
      <h3>Outdated Content (${(auditData.issues.contentQuality.staleLastModified || []).length})</h3>
      ${renderTableSection(auditData.issues.contentQuality.staleLastModified, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Last Modified', render: item => item.lastModified },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Thin Content (${(auditData.issues.contentQuality.thinContent || []).length})</h3>
      ${renderTableSection(auditData.issues.contentQuality.thinContent, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Word Count', render: item => item.words },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Readability Issues (${(auditData.issues.contentQuality.readability || []).length})</h3>
      ${renderTableSection(auditData.issues.contentQuality.readability, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'F-K Score', render: item => item.score.toFixed(1) },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Outdated Meta Years (${(auditData.issues.contentQuality.outdatedMetaYear || []).length})</h3>
      ${renderTableSection(auditData.issues.contentQuality.outdatedMetaYear, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Field', render: item => item.field },
        { header: 'Years', render: item => item.years },
        { header: 'Original Text', render: item => item.original },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Large HTML (${(auditData.issues.contentQuality.largeHTML || []).length})</h3>
      ${renderTableSection(auditData.issues.contentQuality.largeHTML, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Size (bytes)', render: item => item.size ? item.size.toLocaleString() : 'N/A' },
        { header: 'DOM Size (bytes)', render: item => item.totalDom ? item.totalDom.toLocaleString() : 'N/A' },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
    </section>
    
    <!-- Metadata & SEO Issues Section -->
    <section id=\"metadata-seo-issues\">
      <h2>Metadata & SEO Issues</h2>
      
      <h3>Title Length Issues (${(auditData.issues.metadataSEO.titleLength || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.titleLength, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Length', render: item => `${item.length} characters` },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Description Length Issues (${(auditData.issues.metadataSEO.descriptionLength || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.descriptionLength, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Length', render: item => `${item.length} characters` },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Missing Titles (${(auditData.issues.metadataSEO.missingTitle || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.missingTitle, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Missing Descriptions (${(auditData.issues.metadataSEO.missingDescription || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.missingDescription, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Duplicate Titles (${(auditData.issues.metadataSEO.duplicateTitle || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.duplicateTitle, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Title', render: item => item.title },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Duplicate Descriptions (${(auditData.issues.metadataSEO.duplicateDescription || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.duplicateDescription, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Description', render: item => item.description },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>H1 Issues (${(auditData.issues.metadataSEO.h1Issues || []).length})</h3>
      ${renderTableSection(auditData.issues.metadataSEO.h1Issues, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'H1 Count', render: item => item.h1Count },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
    </section>
    
    <!-- Internal Linking Issues Section -->
    <section id=\"internal-linking-issues\">
      <h2>Internal Linking Issues</h2>
      
      <h3>Excessive Click Depth (${(auditData.issues.internalLinking.excessiveClickDepth || []).length})</h3>
      ${renderTableSection(auditData.issues.internalLinking.excessiveClickDepth, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Click Depth', render: item => item.depth },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Orphan Pages (${(auditData.issues.internalLinking.orphanPages || []).length})</h3>
      ${renderTableSection(auditData.issues.internalLinking.orphanPages, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
      
      <h3>Low Internal Links (${(auditData.issues.internalLinking.lowInternalLinks || []).length})</h3>
      ${renderTableSection(auditData.issues.internalLinking.lowInternalLinks, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Internal Links', render: item => item.links },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
    </section>
    
    <!-- Performance Issues Section -->
    <section id=\"performance-issues\">
      <h2>Performance Issues</h2>
      
      <h3>Underperforming Content (${(auditData.issues.underperformingContent || []).length})</h3>
      ${renderTableSection(auditData.issues.underperformingContent, [
        { header: 'URL', class: 'url-cell', render: item => `<a href=\"${item.url}\" target=\"_blank\">${item.url}</a>` },
        { header: 'Clicks', render: item => item.clicks },
        { header: 'Impressions', render: item => item.impressions },
        { header: 'Last Modified', render: item => item.lastModified },
        { header: 'Recommendation', class: 'todo-cell', render: item => item.todo }
      ])}
    </section>

    <section id=\"all-pages\">
      <h2>All Pages Overview</h2>
      <p>Below is a summary of all pages analyzed with their respective issues flagged.</p>
      
      ${(() => {
        const items = auditData.pages || [];
        const showInitial = 10; // Number of rows to show initially
        const hasMoreItems = items.length > showInitial;
        const initialItems = hasMoreItems ? items.slice(0, showInitial) : items;
        const hiddenItems = hasMoreItems ? items.slice(showInitial) : [];
        
        return `
          <table class=\"paginated-table pages-table\">
            <thead>
              <tr>
                <th>URL</th>
                <th>Issues</th>
                <th>Clicks</th>
                <th>Impressions</th>
              </tr>
            </thead>
            <tbody class=\"initial-rows\">
              ${initialItems.map(page => `
                <tr>
                  <td class=\"url-cell\"><a href=\"${page.url}\" target=\"_blank\">${page.url}</a></td>
                  <td>${page.flags.map(flag => `<span class=\"flag\">${formatFlagName(flag)}</span>`).join('')}</td>
                  <td>${page.clicks !== null ? page.clicks : 'N/A'}</td>
                  <td>${page.impressions !== null ? page.impressions : 'N/A'}</td>
                </tr>
              `).join('')}
            </tbody>
            ${hasMoreItems ? `
              <tbody class=\"hidden-rows\" style=\"display: none;\">
                ${hiddenItems.map(page => `
                  <tr>
                    <td class=\"url-cell\"><a href=\"${page.url}\" target=\"_blank\">${page.url}</a></td>
                    <td>${page.flags.map(flag => `<span class=\"flag\">${formatFlagName(flag)}</span>`).join('')}</td>
                    <td>${page.clicks !== null ? page.clicks : 'N/A'}</td>
                    <td>${page.impressions !== null ? page.impressions : 'N/A'}</td>
                  </tr>
                `).join('')}
              </tbody>
            ` : ''}
          </table>
          ${hasMoreItems ? `
            <div class=\"table-pagination\">
              <button class=\"show-more-button\" onclick=\"toggleRows(this)\">Show All (${items.length} rows)</button>
            </div>
          ` : ''}
        `;
      })()}
    </section>
    
    <section id=\"next-steps\">
      <h2>Recommended Next Steps</h2>
      <p>Based on our analysis, we recommend the following actions to improve your content performance:</p>
      
      <div class=\"recommendations\">
        <h4>Priority Actions</h4>
        <ul>
          ${auditData.summary.issues['404'] > 0 ? 
            `<li>Fix 404 errors by restoring pages or implementing proper redirects</li>` : ''}
          ${auditData.summary.issues.redirects > 0 ? 
            `<li>Update internal links to point directly to final URLs instead of through redirects</li>` : ''}
          ${auditData.summary.issues.thin > 0 ? 
            `<li>Expand thin content pages to at least 1,500 words with valuable, unique information</li>` : ''}
          ${auditData.summary.issues.outdated > 0 ? 
            `<li>Update all content that hasn't been refreshed in the last 12 months</li>` : ''}
          ${auditData.summary.issues.missingOrDuplicateMeta > 0 ? 
            `<li>Add unique meta descriptions to all pages missing them</li>` : ''}
          ${auditData.summary.issues.titleLen > 0 ? 
            `<li>Optimize page titles to be between 40-60 characters</li>` : ''}
          ${auditData.summary.issues.descriptionLen > 0 ? 
            `<li>Optimize meta descriptions to be between 70-155 characters</li>` : ''}
          ${auditData.summary.issues.readability > 0 ? 
            `<li>Improve content readability by simplifying language and shortening sentences</li>` : ''}
          ${auditData.summary.issues.underperforming > 0 ? 
            `<li>Identify keywords with potential for pages with high impressions but low clicks</li>` : ''}
          ${auditData.summary.issues.orphan > 0 ? 
            `<li>Create internal links to orphan pages to improve crawlability</li>` : ''}
          ${auditData.summary.issues.lowInternalLinks > 0 ? 
            `<li>Improve internal linking between related content</li>` : ''}
          <li>Implement a content calendar to regularly refresh content</li>
          <li>Conduct keyword research to identify new content opportunities</li>
        </ul>
      </div>
      
      <h3>Implementation Timeline</h3>
      <p>We recommend addressing these issues in the following order:</p>
      
      <ol>
        <li><strong>Immediate (1-2 weeks):</strong> Fix technical issues like 404 errors, redirects, missing meta descriptions, and outdated year references.</li>
        <li><strong>Short-term (2-4 weeks):</strong> Update thin content and improve readability on key pages.</li>
        <li><strong>Medium-term (1-2 months):</strong> Refresh outdated content, especially on high-impression pages.</li>
        <li><strong>Long-term (2-3 months):</strong> Implement a content calendar to regularly update content and prevent future staleness.</li>
      </ol>
    </section>
  </main>

  <footer>
    <div class=\"container\">
      <div class=\"footer-content\">
        <div class=\"company-info\">
          <p>Report generated by <strong>${companyName}</strong></p>
          <a href=\"${companyWebsite}\" class=\"company-website\" target=\"_blank\">${companyWebsite}</a>
        </div>
        <p class=\"date-generated\">Generated on ${formattedDate}</p>
      </div>
    </div>
  </footer>
</body>
</html>`
}];"
      },
      "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
const input = $('Get RAW Audit Data').first().json;

// Initialize an array to store the new items
const output = [];

// Loop through tasks
const tasks = input.tasks || [];
for (const task of tasks) {
    const results = task.result || [];
    for (const result of results) {
        const items = result.items || [];
        for (const page of items) {
            // Only include URLs with status_code 404 or 301
            if (page.url && (page.status_code === 404 || page.status_code === 301)) {
                output.push({ json: { url: page.url, status_code: page.status_code } });
            }
        }
    }
}

// Return filtered URLs with status codes 404 or 301
return output;
"
      },
      "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
const input = items[0].json;

// Access the items array
const linkItems = input.tasks[0].result[0].items;

// Extract the target URL and status code from the first item
const url = linkItems[0].link_to;
const pageStatus = linkItems[0].page_to_status_code;

// Build the output object
const output = {
  URL: url,
  page_to_status_code: pageStatus,
  sources: linkItems.map(item => ({
    type: item.type,
    link_from: item.link_from,
    text: item.text
  }))
};

// Return formatted output
return [{ json: output }];
"
      },
      "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": "=[
  {
    \"id\": \"{{ $('Get RAW Audit Data').first().json.tasks[0].id }}\",
    \"page_to\": \"{{ $json.url }}\"
  }
]",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "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
A 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.

### Set up instructions:
1. 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\".
2. 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\".
3. Update the \"Set Fields\" node with the following information:
- dfs_domain: The website domain you want to crawl.
- company_name: Your company name (Will be displayed on the final report)
- company_website: Your company website URL (Will be displayed on the final report)
- company_logo_url: Your company logo URL (Will be displayed on the final report)
- brand_primary_color: Your primary brand color. (Will be used to customize the final report to your brand's colors)
- brand_secondary_color: Your secondary brand color. (Will be used to customize the final report to your brand's colors)
- 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).
4. Start the workflow. Once done, download the HTML file in the last node \"Download Report\". 

Voilà! You have a comprehensive content audit report ready to be sent to your client via email, customized to your own branding.

**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.1
    }
  ],
  "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 ‘Start’": {
      "main": [
        [
          {
            "node": "Set Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge GSC Data with RAW Data": {
      "main": [
        [
          {
            "node": "Extract 404 & 301",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

功能特点

  • 自动检测新邮件
  • AI智能内容分析
  • 自定义分类规则
  • 批量处理能力
  • 详细的处理日志

技术分析

节点类型及作用

  • Set
  • Manualtrigger
  • Httprequest
  • If
  • Wait

复杂度评估

配置难度:
★★★★☆
维护难度:
★★☆☆☆
扩展性:
★★★★☆

实施指南

前置条件

  • 有效的Gmail账户
  • n8n平台访问权限
  • Google API凭证
  • AI分类服务订阅

配置步骤

  1. 在n8n中导入工作流JSON文件
  2. 配置Gmail节点的认证信息
  3. 设置AI分类器的API密钥
  4. 自定义分类规则和标签映射
  5. 测试工作流执行
  6. 配置定时触发器(可选)

关键参数

参数名称 默认值 说明
maxEmails 50 单次处理的最大邮件数量
confidenceThreshold 0.8 分类置信度阈值
autoLabel true 是否自动添加标签

最佳实践

优化建议

  • 定期更新AI分类模型以提高准确性
  • 根据邮件量调整处理批次大小
  • 设置合理的分类置信度阈值
  • 定期清理过期的分类规则

安全注意事项

  • 妥善保管API密钥和认证信息
  • 限制工作流的访问权限
  • 定期审查处理日志
  • 启用双因素认证保护Gmail账户

性能优化

  • 使用增量处理减少重复工作
  • 缓存频繁访问的数据
  • 并行处理多个邮件分类任务
  • 监控系统资源使用情况

故障排除

常见问题

邮件未被正确分类

检查AI分类器的置信度阈值设置,适当降低阈值或更新训练数据。

Gmail认证失败

确认Google API凭证有效且具有正确的权限范围,重新进行OAuth授权。

调试技巧

  • 启用详细日志记录查看每个步骤的执行情况
  • 使用测试邮件验证分类逻辑
  • 检查网络连接和API服务状态
  • 逐步执行工作流定位问题节点

错误处理

工作流包含以下错误处理机制:

  • 网络超时自动重试(最多3次)
  • API错误记录和告警
  • 处理失败邮件的隔离机制
  • 异常情况下的回滚操作