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分类服务订阅
配置步骤
- 在n8n中导入工作流JSON文件
- 配置Gmail节点的认证信息
- 设置AI分类器的API密钥
- 自定义分类规则和标签映射
- 测试工作流执行
- 配置定时触发器(可选)
关键参数
| 参数名称 | 默认值 | 说明 |
|---|---|---|
| maxEmails | 50 | 单次处理的最大邮件数量 |
| confidenceThreshold | 0.8 | 分类置信度阈值 |
| autoLabel | true | 是否自动添加标签 |
最佳实践
优化建议
- 定期更新AI分类模型以提高准确性
- 根据邮件量调整处理批次大小
- 设置合理的分类置信度阈值
- 定期清理过期的分类规则
安全注意事项
- 妥善保管API密钥和认证信息
- 限制工作流的访问权限
- 定期审查处理日志
- 启用双因素认证保护Gmail账户
性能优化
- 使用增量处理减少重复工作
- 缓存频繁访问的数据
- 并行处理多个邮件分类任务
- 监控系统资源使用情况
故障排除
常见问题
邮件未被正确分类
检查AI分类器的置信度阈值设置,适当降低阈值或更新训练数据。
Gmail认证失败
确认Google API凭证有效且具有正确的权限范围,重新进行OAuth授权。
调试技巧
- 启用详细日志记录查看每个步骤的执行情况
- 使用测试邮件验证分类逻辑
- 检查网络连接和API服务状态
- 逐步执行工作流定位问题节点
错误处理
工作流包含以下错误处理机制:
- 网络超时自动重试(最多3次)
- API错误记录和告警
- 处理失败邮件的隔离机制
- 异常情况下的回滚操作