自动化SEO健康监控与报告
高级
这是一个Market Research, Multimodal AI领域的自动化工作流,包含 16 个节点。主要使用 If, Set, Code, Cron, Html 等节点。 网站SEO健康分析(Google Sheets、PDF报告和Gmail告警)
前置要求
- •Google 账号和 Gmail API 凭证
- •Google Drive API 凭证
- •可能需要目标 API 的认证凭证
- •Google Sheets API 凭证
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
"id": "FhqpDaUdWSIToAh4",
"meta": {
"instanceId": "14e4c77104722ab186539dfea5182e419aecc83d85963fe13f6de862c875ebfa",
"templateCredsSetupCompleted": true
},
"name": "Automated SEO Health Monitoring & Reporting",
"tags": [],
"nodes": [
{
"id": "5b417215-e917-4183-b57a-66bfa835fbe1",
"name": "便签",
"type": "n8n-nodes-base.stickyNote",
"position": [
-290,
-600
],
"parameters": {
"width": 2460,
"height": 860,
"content": "# **Automated SEO Health Monitoring & Reporting**\n\n## This workflow runs daily to analyze websites from Google Sheets, evaluate their SEO health, send alerts if performance is low, and generate PDF reports stored in Google Drive.\n"
},
"typeVersion": 1
},
{
"id": "2733c633-858e-4a0f-807f-79727f3bd178",
"name": "便签1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-280,
300
],
"parameters": {
"width": 2460,
"height": 2300,
"content": "# **Node Breakdown & Descriptions:**\n\n# 1. **Daily 9 AM Trigger**\n\n## Triggers the workflow **every day at 9 AM** to start the SEO health monitoring pipeline.\n\n# 2. **Fetch Website Links**\n\n## Reads a list of **website URLs** from a predefined Google Sheet where domains to be monitored are stored.\n\n# 3. **Attach Config & Timeout**\n\n## Takes the website links and attaches configuration values like **timeout** and other metadata for each request.\n\n# 4. **Fetch Website HTML**\n\n## Fetches the **raw HTML content** of each website URL to prepare it for SEO analysis.\n\n# 5. **Analyze HTML (SEO Basics)**\n\n## Parses the HTML content and extracts SEO-related fields like **title, meta description, H1 count, canonical link, robots tag, and image alt attributes**.\n\n# 6. **SEO Health Check & Score**\n\n## Runs a **health scoring algorithm** on the extracted SEO content, checking for issues and calculating the final **performance score**.\n\n# 7. **Check Performance < 50**\n\n## Evaluates whether the website’s SEO **performance score is below 50**.\n\n## * **True branch** → Alert email + log poor performance.\n## * **False branch** → Generate full report + store results.\n\n# 8. **Send Alert Email (Low SEO Score)**\n\n## If performance is **< 50**, sends an **alert email** to the monitoring team with details of the failed SEO health check.\n\n# 9. **Merge Alert & Normal Results**\n\n## Merges results from both branches (low-performing and acceptable-performing sites) so all can be logged in the sheet.\n\n# 10. **Update Performance Log**\n\n## Updates the Google Sheet with the **performance score and timestamp** for each domain that was analyzed.\n\n# 11. **Generate SEO Report (HTML)**\n\n## For websites that passed the performance threshold, generates a **detailed HTML SEO report** with findings and summary.\n\n# 12. **Convert HTML to PDF**\n\n## Sends the generated HTML to **PDF.co API** to convert it into a **PDF SEO report**.\n\n# 13. **Download PDF File**\n\n## Downloads the **generated PDF file** from the PDF.co temporary storage URL.\n\n# 14. **Save SEO Report to Drive**\n\n## Uploads the **PDF SEO Report** into the configured Google Drive folder for record-keeping and future reference.\n"
},
"typeVersion": 1
},
{
"id": "41837314-159b-4d19-91ae-dfa75519a762",
"name": "Daily 9 AM Trigger",
"type": "n8n-nodes-base.cron",
"position": [
-220,
-160
],
"parameters": {
"triggerTimes": {
"item": [
{
"hour": 9
}
]
}
},
"typeVersion": 1
},
{
"id": "4c9372e7-50e3-42e3-96ad-90bd6cf38feb",
"name": "Fetch Website Links",
"type": "n8n-nodes-base.googleSheets",
"position": [
0,
-160
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": 228478758,
"cachedResultUrl": "",
"cachedResultName": "Website_Seo_Testing"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1cQ-TBf3-dqo7njDYzYpxpASYFvEp8lIzH7vpIqTLcwc",
"cachedResultUrl": "",
"cachedResultName": "N8N_Email_Attachment"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"id": "8jNhwHAuUPuYWS1W",
"name": "Google Sheets account 17"
}
},
"typeVersion": 4.6
},
{
"id": "0fc6228a-9783-4f66-a777-86f7dadcd440",
"name": "Attach Config & Timeout",
"type": "n8n-nodes-base.set",
"position": [
220,
-160
],
"parameters": {
"fields": {
"values": [
{
"name": "siteUrl",
"stringValue": "={{ $json[\"Website Link\"] }}"
},
{
"name": "timeoutMs",
"stringValue": "2000"
}
]
},
"options": {}
},
"typeVersion": 3
},
{
"id": "70be1b7b-880a-4f81-9ee0-92d3003153ad",
"name": "Fetch Website HTML",
"type": "n8n-nodes-base.httpRequest",
"position": [
440,
-160
],
"parameters": {
"url": "={{$json[\"siteUrl\"]}}",
"options": {
"timeout": "={{$json[\"timeoutMs\"]}}"
}
},
"typeVersion": 4
},
{
"id": "93ca197e-4579-4703-b457-f57600fb574c",
"name": "Analyze HTML (SEO Basics)",
"type": "n8n-nodes-base.code",
"position": [
660,
-160
],
"parameters": {
"jsCode": "// Robust SEO analyzer:\n// - Accepts html from item.json.body OR item.json.data OR item.binary.data (base64)\n// - Guesses siteUrl from canonical/og:url/base if missing (enhanced with JSON-LD, home/logo, dominant domain, form/asset hosts, first absolute URL)\n// - Falls back to og:description if meta[name=\"description\"] missing\n// - Returns: url, seo{}, summary{}, issues[], brokenLinks[], critical, plus config passthrough\n\nconst out = [];\n\nfunction getHtmlFromItem(item) {\n // Prefer explicit fields first\n if (typeof item.json.body === 'string' && item.json.body.trim()) return { html: item.json.body, source: 'json.body' };\n if (typeof item.json.data === 'string' && item.json.data.trim()) return { html: item.json.data, source: 'json.data' };\n\n // Binary (e.g., from HTTP node set to \"File\")\n if (item.binary && item.binary.data && item.binary.data.data) {\n try {\n const decoded = Buffer.from(item.binary.data.data, 'base64').toString('utf8');\n if (decoded && /<html[^>]*>/i.test(decoded)) return { html: decoded, source: 'binary.data' };\n } catch {}\n }\n return { html: '', source: 'none' };\n}\n\nfor (const item of items) {\n const { html, source: htmlSource } = getHtmlFromItem(item);\n\n // Pull config if present (still works with your Set Config + Merge approach)\n const timeoutMs = Number(item.json.timeoutMs ?? 5000);\n const maxLinkChecks = Number(item.json.maxLinkChecks ?? 30);\n\n // helpers\n const issues = [];\n const warn = (msg) => issues.push({ level: \"warning\", msg });\n const crit = (msg) => issues.push({ level: \"critical\", msg });\n\n const grab = (re, s) => {\n const m = re.exec(s);\n return m ? m[1] : null;\n };\n const grabAll = (re, s) => {\n const arr = [];\n let m;\n while ((m = re.exec(s)) !== null) arr.push(m[1]);\n return arr;\n };\n\n // --- ADDED HELPERS (safe additions) ---\n const ABS_RE = /^https?:\\/\\//i;\n const isAbs = (u) => ABS_RE.test(u);\n const toAbs = (maybeUrl, base) => { try { return new URL(maybeUrl, base).href; } catch { return null; } };\n const originOf = (u) => { try { const x = new URL(u); return `${x.protocol}//${x.host}/`; } catch { return null; } };\n const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };\n\n // Third-party patterns to ignore when inferring the site's own host\n const THIRD_PARTY = /(googleapis\\.com|gstatic\\.com|googletagmanager\\.com|google-analytics\\.com|doubleclick\\.net|facebook\\.com|twitter\\.com|instagram\\.com|youtube\\.com|vimeo\\.com|cloudfront\\.net|fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|ajax\\.googleapis\\.com)/i;\n\n // Parse JSON-LD to find a plausible page URL\n function jsonLdUrl(htmlText) {\n const blocks = [];\n let m;\n const re = /<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi;\n while ((m = re.exec(htmlText)) !== null) blocks.push(m[1]);\n for (const raw of blocks) {\n try {\n const data = JSON.parse(raw.trim());\n const nodes = Array.isArray(data['@graph']) ? data['@graph'] : [data];\n for (const node of nodes) {\n if (node['@type'] && /WebPage/i.test(node['@type']) && typeof node.url === 'string' && node.url) return node.url;\n if (node.mainEntityOfPage) {\n if (typeof node.mainEntityOfPage === 'string') return node.mainEntityOfPage;\n if (typeof node.mainEntityOfPage === 'object' && typeof node.mainEntityOfPage['@id'] === 'string') return node.mainEntityOfPage['@id'];\n }\n if (typeof node.url === 'string' && node.url) return node.url;\n }\n } catch {}\n }\n return null;\n }\n\n // Try to find a home-like URL (rel=home / logo link)\n function homeLikeUrl(htmlText) {\n const relHome = grab(/<link[^>]*rel=[\"']home[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, htmlText);\n if (relHome) return relHome;\n const aRelHome = grab(/<a[^>]*rel=[\"']home[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, htmlText);\n if (aRelHome) return aRelHome;\n const aLogo = grab(/<a[^>]*class=[\"'][^\"']*\\blogo\\b[^\"']*[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, htmlText)\n || grab(/<a[^>]*aria-label=[\"'][^\"']*home[^\"']*[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, htmlText);\n return aLogo || null;\n }\n\n // Compute a dominant first-party domain by counting absolute href/src/action attributes (skip common CDNs)\n function dominantDomain(htmlText) {\n const attrRe = /\\b(?:href|src|action)\\s*=\\s*[\"']([^\"']+)[\"']/gi;\n const counts = new Map();\n let m;\n while ((m = attrRe.exec(htmlText)) !== null) {\n const u = (m[1] || '').trim();\n if (!u || !isAbs(u) || THIRD_PARTY.test(u)) continue;\n try {\n const h = new URL(u).host;\n counts.set(h, (counts.get(h) || 0) + 1);\n } catch {}\n }\n let best = null, n = 0;\n for (const [h, c] of counts.entries()) if (c > n) { best = h; n = c; }\n return best ? `https://${best}/` : null;\n }\n\n // Extra fallback: prefer form action or stylesheet/script host\n function firstPartyByFormOrAsset(htmlText) {\n // <form action=\"...\">\n const formAction = grab(/<form\\b[^>]*action=[\"']([^\"']+)[\"']/i, htmlText);\n if (formAction && isAbs(formAction) && !THIRD_PARTY.test(formAction)) {\n const o = originOf(formAction);\n if (o) return o;\n }\n // Stylesheets and scripts that are absolute and not third-party\n const assets = grabAll(/\\b(?:href|src)\\s*=\\s*[\"']([^\"']+)[\"']/gi, htmlText)\n .filter(u => isAbs(u) && !THIRD_PARTY.test(u));\n for (const u of assets) {\n const o = originOf(u);\n if (o) return o;\n }\n return null;\n }\n\n // If siteUrl wasn't injected by Set Config or upstream merge, try to infer it\n let siteUrl = item.json.siteUrl || null;\n const canonical = grab(/<link[^>]*rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, html);\n const ogUrl = grab(/<meta[^>]*property=[\"']og:url[\"'][^>]*content=[\"']([^\"']+)[\"'][^>]*>/i, html);\n const baseHref = grab(/<base[^>]*href=[\"']([^\"']+)[\"'][^>]*>/i, html);\n\n if (!siteUrl) {\n siteUrl = canonical || ogUrl || baseHref || \"https://example.com\";\n }\n\n // --- ENHANCEMENT: resolve relatives + alternate signals + normalize ---\n const domainByVoting = dominantDomain(html);\n const bestBase = baseHref || domainByVoting || null;\n\n const candidateIsPlaceholder = siteUrl === \"https://example.com\";\n const candidateIsRelative = !!siteUrl && !isAbs(siteUrl);\n\n if (candidateIsPlaceholder || candidateIsRelative) {\n // Prefer canonical (resolved), then og:url (resolved)\n let resolved = null;\n if (canonical) resolved = isAbs(canonical) ? canonical : (bestBase ? toAbs(canonical, bestBase) : null);\n if (!resolved && ogUrl) resolved = isAbs(ogUrl) ? ogUrl : (bestBase ? toAbs(ogUrl, bestBase) : null);\n\n // JSON-LD candidates (WebPage.url / mainEntityOfPage / url)\n const ld = jsonLdUrl(html);\n if (!resolved && ld) resolved = isAbs(ld) ? ld : (bestBase ? toAbs(ld, bestBase) : null);\n\n // Home/logo links as a hint\n const home = homeLikeUrl(html);\n if (!resolved && home) resolved = isAbs(home) ? home : (bestBase ? toAbs(home, bestBase) : null);\n\n // Fall back to dominant domain origin\n if (!resolved && domainByVoting) resolved = domainByVoting;\n\n // NEW: prefer form action / asset host\n if (!resolved) {\n const formOrAsset = firstPartyByFormOrAsset(html);\n if (formOrAsset) resolved = formOrAsset;\n }\n\n // NEW: last-resort — first absolute non-third-party URL's origin\n if (!resolved) {\n const firstAbs = grab(/\\b(?:href|src|action)\\s*=\\s*[\"'](https?:\\/\\/[^\"']+)[\"']/i, html);\n if (firstAbs && !THIRD_PARTY.test(firstAbs)) {\n const o = originOf(firstAbs);\n if (o) resolved = o;\n }\n }\n\n // Last resort: keep what we had\n siteUrl = resolved || siteUrl;\n }\n\n // Normalize to site origin (scheme + host + trailing slash)\n const normalizedOrigin = originOf(siteUrl);\n if (normalizedOrigin) siteUrl = normalizedOrigin;\n if (!/\\/$/.test(siteUrl)) siteUrl = siteUrl + '/';\n\n // basic SEO fields\n const title = grab(/<title[^>]*>([\\s\\S]{0,2000}?)<\\/title>/i, html)?.trim() || null;\n\n // Prefer meta[name=\"description\"]; fall back to og:description if missing\n let metaDesc =\n grab(/<meta[^>]*name=[\"']description[\"'][^>]*content=[\"']([^\"']{0,4000})[\"'][^>]*>/i, html) ||\n grab(/<meta[^>]*property=[\"']og:description[\"'][^>]*content=[\"']([^\"']{0,4000})[\"'][^>]*>/i, html) ||\n null;\n\n const robots = grab(/<meta[^>]*name=[\"']robots[\"'][^>]*content=[\"']([^\"']+)[\"'][^>]*>/i, html);\n const h1s = grabAll(/<h1\\b[^>]*>([\\s\\S]*?)<\\/h1>/gi, html);\n\n // checks\n if (!title) crit(\"Missing <title> tag.\");\n if (!metaDesc) warn(\"Missing meta description.\");\n if (metaDesc && (metaDesc.length < 50 || metaDesc.length > 160)) {\n warn(`Meta description suboptimal length (${metaDesc.length}).`);\n }\n if (h1s.length === 0) warn(\"Missing <h1>.\");\n if (h1s.length > 1) warn(`Multiple <h1> tags (${h1s.length}).`);\n if (!canonical) warn(\"Missing canonical link.\");\n if (robots && /noindex/i.test(robots)) crit('robots meta includes \"noindex\".');\n if (robots && /nofollow/i.test(robots)) warn('robots meta includes \"nofollow\".');\n\n // images without alt\n const imgTags = html.match(/<img\\b[^>]*>/gi) || [];\n let imagesMissingAlt = 0;\n for (const img of imgTags) {\n const hasAlt = /alt\\s*=\\s*[\"'][^\"']*[\"']/i.test(img);\n if (!hasAlt) imagesMissingAlt++;\n }\n if (imagesMissingAlt > 0) warn(`${imagesMissingAlt} image(s) without alt.`);\n\n // internal link checking (HEAD, fallback GET)\n let host = null;\n try { host = new URL(siteUrl).host; } catch {}\n\n const linkRe = /<a\\b[^>]*href=[\"']([^\"']+)[\"'][^>]*>/gi;\n const linkHrefs = [];\n let m;\n while ((m = linkRe.exec(html)) !== null && linkHrefs.length < maxLinkChecks) {\n let href = m[1].trim();\n if (!href || /^javascript:|^mailto:|^tel:/i.test(href)) continue;\n try { href = new URL(href, siteUrl).href; } catch { continue; }\n try {\n const u = new URL(href);\n if (!host || u.host === host) linkHrefs.push(u.href);\n } catch {}\n }\n\n const uniqueLinks = [...new Set(linkHrefs)];\n const brokenLinks = [];\n\n async function checkUrl(url) {\n // Prefer HEAD\n try {\n const res = await this.helpers.httpRequest({\n method: 'HEAD',\n url,\n timeout: timeoutMs,\n resolveWithFullResponse: true,\n simple: false,\n });\n const code = res.statusCode || res.status || 0;\n if (code >= 400) brokenLinks.push({ url, status: code });\n return;\n } catch (e) {\n // fall back to GET\n }\n try {\n const res2 = await this.helpers.httpRequest({\n method: 'GET',\n url,\n timeout: timeoutMs,\n resolveWithFullResponse: true,\n simple: false,\n });\n const code2 = res2.statusCode || res2.status || 0;\n if (code2 >= 400) brokenLinks.push({ url, status: code2 });\n } catch (e2) {\n brokenLinks.push({ url, status: 'timeout/error' });\n }\n }\n\n // Sequential (gentle). Raise concurrency if you control the target.\n for (const u of uniqueLinks) {\n /* eslint-disable no-await-in-loop */\n await checkUrl.call(this, u);\n }\n\n if (brokenLinks.length > 0) crit(`${brokenLinks.length} broken internal link(s).`);\n\n const summary = {\n url: siteUrl,\n titlePresent: !!title,\n hasMetaDescription: !!metaDesc,\n metaDescriptionLength: metaDesc ? metaDesc.length : 0,\n h1Count: h1s.length,\n hasCanonical: !!canonical,\n robots: robots || null,\n imagesMissingAlt,\n internalLinksChecked: uniqueLinks.length,\n brokenLinksCount: brokenLinks.length,\n htmlSource\n };\n\n const critical = issues.some(i => i.level === \"critical\");\n\n out.push({\n json: {\n url: siteUrl,\n seo: {\n title,\n metaDesc,\n robots: robots || null,\n canonical: canonical || null,\n h1Count: h1s.length,\n },\n summary,\n brokenLinks,\n issues,\n critical,\n // pass through any config if present\n driveFolderId: item.json.driveFolderId,\n slackChannel: item.json.slackChannel,\n psiStrategy: item.json.psiStrategy || \"mobile\",\n }\n });\n}\n\nreturn out;\n"
},
"typeVersion": 2
},
{
"id": "bad0e4d4-a40e-4434-a46a-797d4067cde7",
"name": "SEO Health Check & Score",
"type": "n8n-nodes-base.code",
"position": [
880,
-160
],
"parameters": {
"jsCode": "// // Node name: PSI (with backoff)\n// // Input: item.json.url (or summary.url), item.json.psiStrategy; env PSI_API_KEY\n// // Output: { psi: { ok, performance, rawError, testedUrl, fetchedAt, strategy } }\n\n// const KEY = $env.PSI_API_KEY || 'AIzaSyDb-9zvazRVz2w105NPec89NSTKN9-Ratg';\n// const ENDPOINT = 'https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed';\n\n// async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }\n\n// async function runPSI(url, strategy, quotaUser, attempts = 4) {\n// let lastErr = null;\n// for (let i = 0; i < attempts; i++) {\n// try {\n// const res = await this.helpers.httpRequest({\n// method: 'GET',\n// url: ENDPOINT,\n// qs: {\n// url,\n// strategy: strategy || 'mobile',\n// category: 'performance',\n// key: KEY,\n// quotaUser: quotaUser || url, // helps per-user quota accounting\n// },\n// json: true,\n// resolveWithFullResponse: false,\n// simple: false, // do not throw on non-2xx\n// });\n\n// // If Google returned an HTML \"Sorry...\" page, normalize it to an error\n// if (typeof res === 'string' && /Sorry/i.test(res) && /automated queries/i.test(res)) {\n// throw new Error('blocked_by_google_sorry_page');\n// }\n// if (res?.error?.message) {\n// // 429 / quota / abuse, retry with backoff\n// lastErr = new Error(res.error.message);\n// } else {\n// return res; // success JSON with lighthouseResult\n// }\n// } catch (e) {\n// lastErr = e;\n// }\n\n// // Exponential backoff with jitter: 1s, 2s, 4s, 8s (+random)\n// const waitMs = (2 ** i) * 1000 + Math.floor(Math.random() * 400);\n// await sleep(waitMs);\n// }\n// throw lastErr || new Error('psi_failed');\n// }\n\n// const out = [];\n// for (const item of items) {\n// const siteUrl = item.json.url || item.json.summary?.url || item.json.siteUrl || 'https://example.com';\n// const strategy = item.json.psiStrategy || 'mobile';\n\n// let psi = { ok: false, performance: null, rawError: null, testedUrl: null, fetchedAt: null, strategy: null };\n// try {\n// const raw = await runPSI.call(this, siteUrl, strategy, siteUrl);\n// const score = raw?.lighthouseResult?.categories?.performance?.score;\n// psi.performance = (typeof score === 'number') ? Math.round(score * 100) : null;\n// psi.ok = psi.performance !== null;\n// psi.testedUrl = raw?.id || null;\n// psi.fetchedAt = raw?.analysisUTCTimestamp || null;\n// psi.strategy = raw?.lighthouseResult?.configSettings?.formFactor || strategy;\n// } catch (e) {\n// psi.rawError = String(e.message || e);\n// }\n\n// out.push({ json: { ...item.json, psi } });\n// }\n// return out;\n\n\n// Node name: PSI (with backoff)\n// Requires: env PSI_API_KEY (no default)\n// Reads site URL from: item.json.url || item.json.summary?.url || item.json.siteUrl\n// Outputs: item.json.psi = { ok, performance, rawError, testedUrl, fetchedAt, strategy }\n\nconst KEY = 'AIzaSyDb-9zvazRVz2w105NPec89NSTKN9-Ratg';\nif (!KEY) {\n // Fail fast so you don't waste retries without a key.\n throw new Error('PSI_API_KEY is not set. Add it to n8n environment and restart.');\n}\n\nconst ENDPOINT = 'https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed';\n\nasync function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }\n\nfunction extractError(e) {\n // Try to normalize different error shapes from n8n/http/got\n if (!e) return 'unknown_error';\n if (typeof e === 'string') return e;\n if (e?.message) return e.message;\n const res = e?.response;\n if (res) {\n const status = res.statusCode || res.status;\n let body = res.body || res.data;\n if (Buffer.isBuffer(body)) body = body.toString('utf8');\n let msg = '';\n try {\n const j = typeof body === 'string' ? JSON.parse(body) : body;\n msg = j?.error?.message || j?.message || '';\n } catch {\n // not JSON\n if (typeof body === 'string') msg = body.slice(0, 200);\n }\n return `HTTP ${status}${msg ? `: ${msg}` : ''}`;\n }\n return 'request_failed';\n}\n\nasync function runPSI(url, strategy, quotaUser, attempts = 6) {\n let lastErr = null;\n for (let i = 0; i < attempts; i++) {\n try {\n const res = await this.helpers.httpRequest({\n method: 'GET',\n url: ENDPOINT,\n qs: {\n url,\n strategy: strategy || 'mobile',\n category: 'performance',\n key: KEY,\n quotaUser: quotaUser || url, // helps distribute quota\n },\n json: true,\n // The helper ignores some libs' flags; we still guard with try/catch.\n // These hints help in newer n8n versions:\n resolveWithFullResponse: false,\n simple: false,\n throwHttpErrors: false,\n ignoreHttpStatusErrors: true,\n });\n\n // If PSI returned a valid JSON with lighthouseResult, we're done.\n if (res?.lighthouseResult?.categories?.performance?.score != null) {\n return res;\n }\n\n // If PSI returned JSON error shape, prepare to retry.\n if (res?.error?.message) {\n lastErr = new Error(res.error.message);\n } else if (typeof res === 'string' && /automated queries/i.test(res)) {\n // Google \"Sorry\" HTML\n lastErr = new Error('blocked_by_google_sorry_page');\n } else {\n // Unknown shape; stringify to inspect\n lastErr = new Error('psi_unknown_response');\n }\n } catch (e) {\n // Network or non-2xx thrown by transport\n const msg = extractError(e);\n lastErr = new Error(msg);\n // If it wasn't a 429-like situation, you can break early.\n if (!/429|rate|quota|limit|blocked/i.test(msg)) break;\n }\n\n // Backoff: 1s, 2s, 4s, 8s, 16s, 32s (+ jitter)\n const waitMs = (2 ** i) * 1000 + Math.floor(Math.random() * 400);\n await sleep(waitMs);\n }\n throw lastErr || new Error('psi_failed');\n}\n\nconst out = [];\nfor (const item of items) {\n const siteUrl = item.json.url || item.json.summary?.url || item.json.siteUrl || 'https://example.com';\n const strategy = item.json.psiStrategy || 'mobile';\n\n const hostForQuota =\n (() => { try { return new URL(siteUrl).host; } catch { return siteUrl; } })();\n\n const psi = { ok: false, performance: null, rawError: null, testedUrl: null, fetchedAt: null, strategy: null };\n\n try {\n const raw = await runPSI.call(this, siteUrl, strategy, hostForQuota);\n const score = raw?.lighthouseResult?.categories?.performance?.score;\n psi.performance = (typeof score === 'number') ? Math.round(score * 100) : null;\n psi.ok = psi.performance !== null;\n psi.testedUrl = raw?.id || null;\n psi.fetchedAt = raw?.analysisUTCTimestamp || null;\n psi.strategy = raw?.lighthouseResult?.configSettings?.formFactor || strategy;\n } catch (e) {\n psi.rawError = extractError(e);\n }\n\n out.push({ json: { ...item.json, psi } });\n}\n\nreturn out;\n\n"
},
"typeVersion": 2
},
{
"id": "cb514485-6246-4500-9587-c7d6db259e63",
"name": "Check Performance < 50",
"type": "n8n-nodes-base.if",
"position": [
1100,
-160
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "1a38884a-0d96-4658-9b87-e6897b9bab2c",
"operator": {
"type": "number",
"operation": "lt"
},
"leftValue": "={{ $json.psi.performance }}",
"rightValue": 50
}
]
}
},
"typeVersion": 2
},
{
"id": "daed86d5-0796-4272-b685-0e48910307a2",
"name": "Send Alert Email (Low SEO Score)",
"type": "n8n-nodes-base.gmail",
"position": [
1320,
-360
],
"webhookId": "7d6ee81e-f4da-442c-a4c3-25dcd5b3ce32",
"parameters": {
"sendTo": "mobile1.wli@gmail.com",
"message": "=<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <title>SEO & Performance Report</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n </head>\n <body style=\"margin:0; padding:0; background:#f6f9fc;\">\n <!-- Wrapper -->\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#f6f9fc;\">\n <tr>\n <td align=\"center\" style=\"padding:24px 12px;\">\n <!-- Container -->\n <table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:600px; max-width:100%; background:#ffffff; border-radius:10px; overflow:hidden; font-family:Arial, Helvetica, sans-serif; color:#111827;\">\n <!-- Header -->\n <tr>\n <td style=\"padding:20px 24px; background:#0f172a; color:#ffffff;\">\n <div style=\"font-size:18px; font-weight:bold;\">SEO & Performance Report</div>\n <div style=\"font-size:12px; opacity:0.85; margin-top:4px;\">Generated for: <a href=\"https://www.weblineindia.com/\" style=\"color:#93c5fd; text-decoration:none;\">{{ $json.summary.url }}</a></div>\n </td>\n </tr>\n\n <!-- Site Overview -->\n <tr>\n <td style=\"padding:16px 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n <tr>\n <td style=\"padding:12px 0;\">\n <div style=\"font-size:14px; color:#6b7280;\">Page Title</div>\n <div style=\"font-size:16px; font-weight:600;\">{{ $json.seo.title }}</div>\n </td>\n </tr>\n <tr>\n <td style=\"padding:6px 0;\">\n <span style=\"display:inline-block; font-size:12px; background:#e5f4ff; color:#075985; padding:6px 10px; border-radius:999px; margin-right:6px;\">robots: {{ $json.seo.robots }}</span>\n <span style=\"display:inline-block; font-size:12px; background:#e5f4ff; color:#075985; padding:6px 10px; border-radius:999px; margin-right:6px;\">canonical present: {{ $json.seo.canonical }}</span>\n <span style=\"display:inline-block; font-size:12px; background:#ecfdf5; color:#065f46; padding:6px 10px; border-radius:999px;\">critical: {{ $json.critical }}</span>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- KPI Row -->\n <tr>\n <td style=\"padding:0 24px 8px 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n <tr>\n <!-- PSI Score -->\n <td valign=\"top\" width=\"50%\" style=\"padding:12px; border:1px solid #e5e7eb; border-radius:8px;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">PageSpeed Insights (mobile)</div>\n <div style=\"font-size:32px; font-weight:700; line-height:1; color:#b45309;\">{{ $json.psi.performance }}</div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:6px;\">Status: <span style=\"color:#16a34a;\">ok</span></div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:2px;\">Tested URL: <a href=\"{{ $json.psi.testedUrl }}\" style=\"color:#2563eb; text-decoration:none;\">{{ $json.psi.testedUrl }}</a></div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:2px;\">Fetched at: 2025-08-14 11:22:47 UTC</div>\n <!-- Legend -->\n <div style=\"font-size:11px; color:#6b7280; margin-top:10px;\">\n <span style=\"color:#16a34a; font-weight:bold;\">90–100</span> Good | \n <span style=\"color:#b45309; font-weight:bold;\">50–89</span> Needs Improvement | \n <span style=\"color:#dc2626; font-weight:bold;\">0–49</span> Poor\n </div>\n </td>\n\n <!-- SEO Summary -->\n <td valign=\"top\" width=\"50%\" style=\"padding:12px; border:1px solid #e5e7eb; border-radius:8px; vertical-align:top;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">SEO Summary</div>\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"font-size:13px;\">\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Meta Description</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">present</span>\n <span style=\"color:#6b7280; margin-left:6px;\">({{ $json.summary.metaDescriptionLength }} chars)</span>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">H1 Count</td>\n <td style=\"padding:4px 0; text-align:right;\">{{ $json.summary.h1Count }}</td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Images missing alt</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">{{ $json.summary.imagesMissingAlt }}</span>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Broken links</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">{{ $json.summary.brokenLinksCount }}</span>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Details: Meta Description -->\n <tr>\n <td style=\"padding:8px 24px 0 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border:1px solid #e5e7eb; border-radius:8px;\">\n <tr>\n <td style=\"padding:12px;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">Meta Description</div>\n <div style=\"font-size:14px; color:#111827;\">\n {{ $json.seo.metaDesc }}\n </div>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Canonical / Robots -->\n <tr>\n <td style=\"padding:12px 24px 0 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border:1px solid #e5e7eb; border-radius:8px;\">\n <tr>\n <td style=\"padding:12px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"font-size:13px;\">\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Canonical URL</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <a href=\"{{ $json.seo.canonical }}\" style=\"color:#2563eb; text-decoration:none;\">{{ $json.seo.canonical }}</a>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Robots</td>\n <td style=\"padding:4px 0; text-align:right;\">{{ $json.seo.robots }}</td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Footer -->\n <tr>\n <td style=\"padding:18px 24px 22px 24px; color:#6b7280; font-size:12px;\">\n This automated report is based on the latest crawl & PSI test (mobile). If you’d like this email to include multiple URLs or a trend chart, reply and I’ll add it.\n </td>\n </tr>\n </table>\n <!-- /Container -->\n </td>\n </tr>\n </table>\n <!-- /Wrapper -->\n </body>\n</html>",
"options": {},
"subject": "Website Health & SEO Audit"
},
"credentials": {
"gmailOAuth2": {
"id": "Ykx3MId2a2uNEP3r",
"name": "Gmail account 20"
}
},
"typeVersion": 2.1
},
{
"id": "407a2d4c-f9e6-4343-b9a2-551bfbde84df",
"name": "Merge Alert & Normal Results",
"type": "n8n-nodes-base.merge",
"position": [
1320,
-160
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "9e9563df-7000-4230-a3cb-1f232c8d1b72",
"name": "Update Performance Log",
"type": "n8n-nodes-base.googleSheets",
"position": [
1540,
-160
],
"parameters": {
"columns": {
"value": {
"Date": "={{ $json.psi.fetchedAt }}",
"Domain": "={{ $json.url.extractDomain() }}",
"Performance": "={{ $json.psi.performance }}",
"Website Link": "={{ $json.url }}"
},
"schema": [
{
"id": "Domain",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Domain",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Website Link",
"type": "string",
"display": true,
"required": false,
"displayName": "Website Link",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Performance",
"type": "string",
"display": true,
"required": false,
"displayName": "Performance",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Date",
"type": "string",
"display": true,
"required": false,
"displayName": "Date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "row_number",
"type": "string",
"display": true,
"removed": true,
"readOnly": true,
"required": false,
"displayName": "row_number",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"Domain"
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "update",
"sheetName": {
"__rl": true,
"mode": "list",
"value": 228478758,
"cachedResultUrl": "",
"cachedResultName": "Website_Seo_Testing"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1cQ-TBf3-dqo7njDYzYpxpASYFvEp8lIzH7vpIqTLcwc",
"cachedResultUrl": "",
"cachedResultName": "N8N_Email_Attachment"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"id": "8jNhwHAuUPuYWS1W",
"name": "Google Sheets account 17"
}
},
"typeVersion": 4.6
},
{
"id": "9355a6c8-da5c-40b4-8999-a0a0eb50d9d5",
"name": "Generate SEO Report (HTML)",
"type": "n8n-nodes-base.html",
"position": [
1320,
40
],
"parameters": {
"html": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <title>SEO & Performance Report</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n </head>\n <body style=\"margin:0; padding:0; background:#f6f9fc;\">\n <!-- Wrapper -->\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#f6f9fc;\">\n <tr>\n <td align=\"center\" style=\"padding:24px 12px;\">\n <!-- Container -->\n <table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:600px; max-width:100%; background:#ffffff; border-radius:10px; overflow:hidden; font-family:Arial, Helvetica, sans-serif; color:#111827;\">\n <!-- Header -->\n <tr>\n <td style=\"padding:20px 24px; background:#0f172a; color:#ffffff;\">\n <div style=\"font-size:18px; font-weight:bold;\">SEO & Performance Report</div>\n <div style=\"font-size:12px; opacity:0.85; margin-top:4px;\">Generated for: <a href=\"https://www.weblineindia.com/\" style=\"color:#93c5fd; text-decoration:none;\">{{ $json.summary.url }}</a></div>\n </td>\n </tr>\n\n <!-- Site Overview -->\n <tr>\n <td style=\"padding:16px 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n <tr>\n <td style=\"padding:12px 0;\">\n <div style=\"font-size:14px; color:#6b7280;\">Page Title</div>\n <div style=\"font-size:16px; font-weight:600;\">{{ $json.seo.title }}</div>\n </td>\n </tr>\n <tr>\n <td style=\"padding:6px 0;\">\n <span style=\"display:inline-block; font-size:12px; background:#e5f4ff; color:#075985; padding:6px 10px; border-radius:999px; margin-right:6px;\">robots: {{ $json.seo.robots }}</span>\n <span style=\"display:inline-block; font-size:12px; background:#e5f4ff; color:#075985; padding:6px 10px; border-radius:999px; margin-right:6px;\">canonical present: {{ $json.seo.canonical }}</span>\n <span style=\"display:inline-block; font-size:12px; background:#ecfdf5; color:#065f46; padding:6px 10px; border-radius:999px;\">critical: {{ $json.critical }}</span>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- KPI Row -->\n <tr>\n <td style=\"padding:0 24px 8px 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\n <tr>\n <!-- PSI Score -->\n <td valign=\"top\" width=\"50%\" style=\"padding:12px; border:1px solid #e5e7eb; border-radius:8px;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">PageSpeed Insights (mobile)</div>\n <div style=\"font-size:32px; font-weight:700; line-height:1; color:#b45309;\">{{ $json.psi.performance }}</div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:6px;\">Status: <span style=\"color:#16a34a;\">ok</span></div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:2px;\">Tested URL: <a href=\"{{ $json.psi.testedUrl }}\" style=\"color:#2563eb; text-decoration:none;\">{{ $json.psi.testedUrl }}</a></div>\n <div style=\"font-size:12px; color:#6b7280; margin-top:2px;\">Fetched at: 2025-08-14 11:22:47 UTC</div>\n <!-- Legend -->\n <div style=\"font-size:11px; color:#6b7280; margin-top:10px;\">\n <span style=\"color:#16a34a; font-weight:bold;\">90–100</span> Good | \n <span style=\"color:#b45309; font-weight:bold;\">50–89</span> Needs Improvement | \n <span style=\"color:#dc2626; font-weight:bold;\">0–49</span> Poor\n </div>\n </td>\n\n <!-- SEO Summary -->\n <td valign=\"top\" width=\"50%\" style=\"padding:12px; border:1px solid #e5e7eb; border-radius:8px; vertical-align:top;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">SEO Summary</div>\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"font-size:13px;\">\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Meta Description</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">present</span>\n <span style=\"color:#6b7280; margin-left:6px;\">({{ $json.summary.metaDescriptionLength }} chars)</span>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">H1 Count</td>\n <td style=\"padding:4px 0; text-align:right;\">{{ $json.summary.h1Count }}</td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Images missing alt</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">{{ $json.summary.imagesMissingAlt }}</span>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Broken links</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <span style=\"background:#ecfdf5; color:#065f46; padding:2px 8px; border-radius:999px;\">{{ $json.summary.brokenLinksCount }}</span>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Details: Meta Description -->\n <tr>\n <td style=\"padding:8px 24px 0 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border:1px solid #e5e7eb; border-radius:8px;\">\n <tr>\n <td style=\"padding:12px;\">\n <div style=\"font-size:14px; color:#6b7280; margin-bottom:6px;\">Meta Description</div>\n <div style=\"font-size:14px; color:#111827;\">\n {{ $json.seo.metaDesc }}\n </div>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Canonical / Robots -->\n <tr>\n <td style=\"padding:12px 24px 0 24px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"border:1px solid #e5e7eb; border-radius:8px;\">\n <tr>\n <td style=\"padding:12px;\">\n <table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"font-size:13px;\">\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Canonical URL</td>\n <td style=\"padding:4px 0; text-align:right;\">\n <a href=\"{{ $json.seo.canonical }}\" style=\"color:#2563eb; text-decoration:none;\">{{ $json.seo.canonical }}</a>\n </td>\n </tr>\n <tr>\n <td style=\"padding:4px 0; color:#374151;\">Robots</td>\n <td style=\"padding:4px 0; text-align:right;\">{{ $json.seo.robots }}</td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n\n <!-- Footer -->\n <tr>\n <td style=\"padding:18px 24px 22px 24px; color:#6b7280; font-size:12px;\">\n This automated report is based on the latest crawl & PSI test (mobile). If you’d like this email to include multiple URLs or a trend chart, reply and I’ll add it.\n </td>\n </tr>\n </table>\n <!-- /Container -->\n </td>\n </tr>\n </table>\n <!-- /Wrapper -->\n </body>\n</html>"
},
"typeVersion": 1.2
},
{
"id": "43688275-562b-4696-8f75-bc1f8ae8bcdc",
"name": "Convert HTML to PDF",
"type": "n8n-nodes-base.httpRequest",
"position": [
1540,
40
],
"parameters": {
"url": "https://api.pdf.co/v1/pdf/convert/from/html",
"method": "POST",
"options": {
"redirect": {
"redirect": {}
}
},
"sendBody": true,
"sendHeaders": true,
"bodyParameters": {
"parameters": [
{
"name": "html",
"value": "={{ $json.html }}"
},
{
"name": "name",
"value": "SEO-Report.pdf"
},
{
"name": "paperSize",
"value": "A4"
},
{
"name": "margins",
"value": "12mm 12mm 12mm 12mm"
},
{
"name": "orientation",
"value": "Portrait"
},
{
"name": "printBackground",
"value": "true"
},
{
"name": "mediaType",
"value": "print"
},
{
"name": "async",
"value": "false"
}
]
},
"headerParameters": {
"parameters": [
{
"name": "x-api-key",
"value": ""
}
]
}
},
"typeVersion": 4.2
},
{
"id": "0bfa99f1-ee76-4b0a-a166-7dad819c47a1",
"name": "Download PDF File",
"type": "n8n-nodes-base.httpRequest",
"position": [
1760,
40
],
"parameters": {
"url": "={{ $json.url }}",
"options": {}
},
"typeVersion": 4.2
},
{
"id": "c827e55f-b760-4c63-8b07-e147d048e050",
"name": "Save SEO Report to Drive",
"type": "n8n-nodes-base.googleDrive",
"position": [
1980,
40
],
"parameters": {
"name": "={{ $('Check Performance < 50').item.json.url.extractDomain().split('.')[1] }}.pdf",
"driveId": {
"__rl": true,
"mode": "list",
"value": "My Drive",
"cachedResultUrl": "",
"cachedResultName": "My Drive"
},
"options": {},
"folderId": {
"__rl": true,
"mode": "list",
"value": "1lpAV7XUx2pByMu58WYbiShuxEiwwLDQK",
"cachedResultUrl": "",
"cachedResultName": "SEO_PDF_Store"
}
},
"credentials": {
"googleDriveOAuth2Api": {
"id": "XgIscg3k3QM4xdr2",
"name": "Google Drive account 9"
}
},
"typeVersion": 3
}
],
"active": false,
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"versionId": "42d51b50-48eb-44ee-bb38-2387900cbf79",
"connections": {
"Download PDF File": {
"main": [
[
{
"node": "Save SEO Report to Drive",
"type": "main",
"index": 0
}
]
]
},
"Daily 9 AM Trigger": {
"main": [
[
{
"node": "Fetch Website Links",
"type": "main",
"index": 0
}
]
]
},
"Fetch Website HTML": {
"main": [
[
{
"node": "Analyze HTML (SEO Basics)",
"type": "main",
"index": 0
}
]
]
},
"Convert HTML to PDF": {
"main": [
[
{
"node": "Download PDF File",
"type": "main",
"index": 0
}
]
]
},
"Fetch Website Links": {
"main": [
[
{
"node": "Attach Config & Timeout",
"type": "main",
"index": 0
}
]
]
},
"Check Performance < 50": {
"main": [
[
{
"node": "Send Alert Email (Low SEO Score)",
"type": "main",
"index": 0
},
{
"node": "Merge Alert & Normal Results",
"type": "main",
"index": 0
}
],
[
{
"node": "Generate SEO Report (HTML)",
"type": "main",
"index": 0
},
{
"node": "Merge Alert & Normal Results",
"type": "main",
"index": 1
}
]
]
},
"Attach Config & Timeout": {
"main": [
[
{
"node": "Fetch Website HTML",
"type": "main",
"index": 0
}
]
]
},
"SEO Health Check & Score": {
"main": [
[
{
"node": "Check Performance < 50",
"type": "main",
"index": 0
}
]
]
},
"Save SEO Report to Drive": {
"main": [
[]
]
},
"Analyze HTML (SEO Basics)": {
"main": [
[
{
"node": "SEO Health Check & Score",
"type": "main",
"index": 0
}
]
]
},
"Generate SEO Report (HTML)": {
"main": [
[
{
"node": "Convert HTML to PDF",
"type": "main",
"index": 0
}
]
]
},
"Merge Alert & Normal Results": {
"main": [
[
{
"node": "Update Performance Log",
"type": "main",
"index": 0
}
]
]
}
}
}常见问题
如何使用这个工作流?
复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。
这个工作流适合什么场景?
高级 - 市场调研, 多模态 AI
需要付费吗?
本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。
相关工作流推荐
使用 HTTP Last-Modified 检查从 Google Sheets 获取职位发布过期和刷新提醒
通过 Google Sheets、HTTP 检查和 Gmail 实现职位发布过期提醒的自动化
If
Set
Code
+6
19 节点WeblineIndia
人力资源
每周创作者竞品创意到Google Sheets(Firecrawl + AI)
使用GPT4和Gemini监控竞争对手并生成内容创意到Google Sheets
If
Set
Code
+7
22 节点Shelly-Ann
市场调研
使用Groq、Gemini和Slack审批系统自动化RSS到Medium发布
通过Groq、Gemini和Slack审批系统实现RSS到Medium发布的自动化流程
If
Set
Code
+16
41 节点ObisDev
内容创作
自动化潜在客户捕获、AI资质鉴定及ElevenLabs个性化语音跟进
基于 OpenAI 和 ElevenLabs 的自动化线索捕获与 AI 个性化语音跟进
If
Set
Code
+10
22 节点Marth
客户培育
生成 PDF 报告的 SWOT 分析生成器
使用 OpenAI、Google Sheets 和 APITemplate PDF 导出生成 SWOT 分析报告
Set
Code
Gmail
+10
40 节点Sebastian/OptiLever
市场调研
太阳能发电监测预警工作流
监控太阳能发电并通过Gmail、Google表格和Slack发送警报
If
Code
Gmail
+5
9 节点WeblineIndia
工程
工作流信息
难度等级
高级
节点数量16
分类2
节点类型11
作者
WeblineIndia
@weblineindiaA Leading Software Engineering, Consulting & Outsourcing Services Company in USA & India serving Clients Globally since 1999.
外部链接
在 n8n.io 查看 →
分享此工作流