8
n8n 中文网amn8n.com

自动化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 &nbsp;|&nbsp;\n                        <span style=\"color:#b45309; font-weight:bold;\">50–89</span> Needs Improvement &nbsp;|&nbsp;\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 &nbsp;|&nbsp;\n                        <span style=\"color:#b45309; font-weight:bold;\">50–89</span> Needs Improvement &nbsp;|&nbsp;\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)可能需要您自行付费。

工作流信息
难度等级
高级
节点数量16
分类2
节点类型11
难度说明

适合高级用户,包含 16+ 个节点的复杂工作流

作者
WeblineIndia

WeblineIndia

@weblineindia

A Leading Software Engineering, Consulting & Outsourcing Services Company in USA & India serving Clients Globally since 1999.

外部链接
在 n8n.io 查看

分享此工作流