AutomationFlowsAI & RAG › SEO QA Agent for Website Audits

SEO QA Agent for Website Audits

Original n8n title: Reputation Engine — SEO Qa Agent

Reputation Engine — SEO QA Agent. Uses httpRequest. Webhook trigger; 28 nodes.

Webhook trigger★★★★☆ complexity28 nodesHTTP Request
AI & RAG Trigger: Webhook Nodes: 28 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "3RYvkOtuOInfaiuZ",
  "name": "Reputation Engine \u2014 SEO QA Agent",
  "description": null,
  "active": true,
  "isArchived": false,
  "nodes": [
    {
      "id": "qa-check-webhook",
      "name": "QA Check Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        300
      ],
      "parameters": {
        "path": "qa-check",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "resolve-urls",
      "name": "Resolve URLs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        300
      ],
      "parameters": {
        "jsCode": "const body = $json.body || $json;\nconst source = body.source || 'operator';\n\nconst SITE_MAP = {\n  sinabarimd: { domain: 'sinabarimd.com', requires_hub_link: false },\n  sinabari_net: { domain: 'sinabari.net', requires_hub_link: true },\n  sinabariplasticsurgery: { domain: 'sinabariplasticsurgery.com', requires_hub_link: true },\n};\n\nlet urls_to_check = [];\n\nif (body.article_url) {\n  urls_to_check.push({\n    site_id: body.site_id,\n    article_url: body.article_url,\n    domain: SITE_MAP[body.site_id]?.domain || body.site_id,\n    requires_hub_link: SITE_MAP[body.site_id]?.requires_hub_link ?? true,\n  });\n} else if (body.article_urls && Array.isArray(body.article_urls)) {\n  for (const entry of body.article_urls) {\n    const sid = entry.site_id || body.site_id;\n    urls_to_check.push({\n      site_id: sid,\n      article_url: entry.article_url || entry.url,\n      domain: SITE_MAP[sid]?.domain,\n      requires_hub_link: SITE_MAP[sid]?.requires_hub_link ?? true,\n    });\n  }\n} else {\n  // Sweep mode\n  const staticData = $getWorkflowStaticData('global');\n  const registry = staticData.url_registry || {};\n  for (const [site_id, articles] of Object.entries(registry)) {\n    for (const article of articles) {\n      urls_to_check.push({\n        site_id,\n        article_url: article.url,\n        domain: SITE_MAP[site_id]?.domain,\n        requires_hub_link: SITE_MAP[site_id]?.requires_hub_link ?? true,\n      });\n    }\n  }\n}\n\nif (urls_to_check.length === 0) {\n  return [{ json: { error: true, message: 'No URLs to check.' } }];\n}\n\nreturn urls_to_check.map(u => ({ json: { ...u, source, checked_at: new Date().toISOString() } }));"
      }
    },
    {
      "id": "fetch-article",
      "name": "Fetch Article Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        800,
        300
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ $json.article_url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "redirect": {
            "redirect": {
              "followRedirects": true
            }
          },
          "timeout": 15000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "run-seo-checks",
      "name": "Run SEO Checks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        300
      ],
      "parameters": {
        "jsCode": "const upstream = $('Resolve URLs').first().json;\nconst site_id = upstream.site_id;\nconst article_url = upstream.article_url;\nconst domain = upstream.domain;\nconst requires_hub_link = upstream.requires_hub_link;\nconst checked_at = upstream.checked_at;\nconst source = upstream.source;\n\nconst html = typeof $json.data === 'string' ? $json.data : (typeof $json === 'string' ? $json : '');\nconst http_ok = html.length > 100;\n\nconst checks = [];\nconst AUTHOR_NAME = 'Dr. Sina Bari, MD';\nconst AUTHOR_ID = 'https://sinabarimd.com/#sinabari';\n\nconst chk = (id, label, passed, detail, severity = 'error') => {\n  checks.push({ id, label, passed, detail, severity });\n};\n\n// 1. Page loads\nchk('http_200', 'Page loads successfully', http_ok, http_ok ? 'OK' : 'Failed to fetch page content');\n\nif (!http_ok) {\n  return [{ json: { site_id, article_url, domain, checked_at, source, score: 0, grade: 'F', passed: false, total_checks: 1, checks, failed_checks: checks.filter(c => !c.passed), warnings: [] } }];\n}\n\n// 2. Title\nconst titleM = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nconst title = titleM?.[1]?.trim() || '';\nchk('title', 'Title tag exists', title.length > 0, title.length ? '\"' + title.slice(0,70) + '\"' : 'Missing');\n\n// 3. Meta description\nconst descM = html.match(/<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+name=[\"']description[\"']/i);\nchk('meta_desc', 'Meta description exists', !!descM, descM ? descM[1].length + ' chars' : 'Missing');\n\n// 4. Canonical\nconst canonM = html.match(/<link\\s+rel=[\"']canonical[\"']\\s+href=[\"']([^\"']*)[\"']/i) || html.match(/<link\\s+href=[\"']([^\"']*)[\"']\\s+rel=[\"']canonical[\"']/i);\nconst canon = canonM?.[1] || '';\nchk('canonical', 'Canonical URL matches page', canon === article_url || canon === article_url.replace(/\\/$/, ''), canon ? 'canonical=' + canon : 'Missing');\n\n// 5. og:type\nconst ogtM = html.match(/<meta\\s+property=[\"']og:type[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+property=[\"']og:type[\"']/i);\nchk('og_type', 'og:type is article', ogtM?.[1]?.toLowerCase() === 'article', ogtM ? ogtM[1] : 'Missing');\n\n// 6. og:site_name\nconst ogsM = html.match(/<meta\\s+property=[\"']og:site_name[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+property=[\"']og:site_name[\"']/i);\nchk('og_site_name', 'og:site_name exists', !!ogsM, ogsM ? ogsM[1] : 'Missing');\n\n// 7. article:published_time\nconst aptM = html.match(/article:published_time[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/content=[\"']([^\"']*)[\"']\\s+property=[\"']article:published_time[\"']/i);\nchk('pub_time', 'article:published_time exists', !!aptM, aptM ? aptM[1] : 'Missing');\n\n// 8. twitter:card\nconst twM = html.match(/twitter:card[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/content=[\"']([^\"']*)[\"']\\s+name=[\"']twitter:card[\"']/i);\nchk('twitter', 'twitter:card exists', !!twM, twM ? twM[1] : 'Missing');\n\n// 9. meta author\nconst authM = html.match(/<meta\\s+name=[\"']author[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+name=[\"']author[\"']/i);\nchk('meta_author', 'Meta author is Dr. Sina Bari, MD', authM?.[1] === AUTHOR_NAME, authM ? authM[1] : 'Missing');\n\n// 10-11. Schema checks\nconst ldBlocks = html.match(/<script[^>]+application\\/ld\\+json[^>]*>([\\s\\S]*?)<\\/script>/gi) || [];\nlet hasArticleType = false, hasAuthorId = false, hasFaq = false, schemaType = '';\nfor (const block of ldBlocks) {\n  try {\n    const inner = block.match(/>([\\s\\S]*?)<\\/script>/i)?.[1] || '{}';\n    const parsed = JSON.parse(inner);\n    const items = parsed['@graph'] ? parsed['@graph'] : [parsed];\n    for (const item of items) {\n      const t = item['@type'];\n      if (t === 'Article' || t === 'MedicalWebPage') { hasArticleType = true; schemaType = t; }\n      if (t === 'FAQPage') hasFaq = true;\n      if (item.author?.['@id'] === AUTHOR_ID) hasAuthorId = true;\n    }\n  } catch (_) {}\n}\nchk('schema_type', 'Schema Article/MedicalWebPage exists', hasArticleType, hasArticleType ? schemaType : 'Missing');\nchk('schema_author_id', 'Schema author @id = sinabarimd.com/#sinabari', hasAuthorId, hasAuthorId ? 'OK' : 'Missing @id');\n\n// 12. FAQPage (warning)\nchk('faq_schema', 'FAQPage schema exists', hasFaq, hasFaq ? 'OK' : 'No FAQPage \u2014 AEO opportunity missed', 'warning');\n\n// 13. Author byline\nconst hasByline = html.includes('<aside') && html.split('<aside')[1]?.includes('sinabarimd.com/about');\nchk('author_byline', 'Author byline with sinabarimd.com/about link', hasByline, hasByline ? 'OK' : 'Missing byline block');\n\n// 14. Hub backlink (satellite sites only)\nif (requires_hub_link) {\n  const hasHub = /href=[\"']https?:\\/\\/(www\\.)?sinabarimd\\.com/i.test(html);\n  chk('hub_backlink', 'Link to sinabarimd.com exists', hasHub, hasHub ? 'OK' : 'No sinabarimd.com link');\n}\n\n// 15. Articles nav link (warning)\nchk('articles_nav', 'Nav has /articles/ link', html.includes('/articles/'), html.includes('/articles/') ? 'OK' : 'Missing', 'warning');\n\n\n// 16. Outbound source links (warning)\nconst extLinkRe = /href=[\"']https?:\\/\\/(?!sinabarimd\\.com|sinabari\\.net|sinabariplasticsurgery\\.com|drsinabari\\.com)[^\"']+[\"']/gi;\nconst extLinks = (html || '').match(extLinkRe) || [];\nchk('outbound_links', 'Has outbound authority links', extLinks.length > 0, extLinks.length ? extLinks.length + ' external links' : 'No outbound links \u2014 add 2-3 for E-E-A-T', 'warning');\n\n// Score (errors only, warnings excluded)\nconst errors = checks.filter(c => c.severity === 'error');\nconst passedErrors = errors.filter(c => c.passed).length;\nconst score = errors.length > 0 ? Math.round((passedErrors / errors.length) * 100) : 100;\nconst grade = score >= 95 ? 'A+' : score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 65 ? 'C' : score >= 50 ? 'D' : 'F';\n\nreturn [{ json: {\n  site_id, article_url, domain, checked_at, source, score, grade,\n  passed: score >= 80,\n  total_checks: checks.length,\n  failed_checks: errors.filter(c => !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  warnings: checks.filter(c => c.severity === 'warning' && !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  checks,\n} }];"
      }
    },
    {
      "id": "store-qa",
      "name": "Store QA Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1400,
        300
      ],
      "parameters": {
        "jsCode": "const result = $json;\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.qa_results) staticData.qa_results = {};\nif (!staticData.qa_history) staticData.qa_history = [];\nif (!staticData.url_registry) staticData.url_registry = {};\n\n// Guard: don't store homepage URLs as article results\nconst url = result.article_url || '';\nif (url.endsWith('/') || !url.includes('/articles/')) {\n  // This is a homepage or non-article URL \u2014 skip article storage\n  return [{ json: { ...result, skipped: true, reason: 'Not an article URL \u2014 use domain QA for homepages' } }];\n}\n\nstaticData.qa_results[result.article_url] = {\n  site_id: result.site_id,\n  score: result.score,\n  grade: result.grade,\n  passed: result.passed,\n  checked_at: result.checked_at,\n  failed_checks: result.failed_checks,\n  warnings: result.warnings,\n};\n\nstaticData.qa_history.push({\n  article_url: result.article_url,\n  site_id: result.site_id,\n  score: result.score,\n  grade: result.grade,\n  checked_at: result.checked_at,\n});\nif (staticData.qa_history.length > 100) staticData.qa_history = staticData.qa_history.slice(-100);\n\nif (!staticData.url_registry[result.site_id]) staticData.url_registry[result.site_id] = [];\nconst existing = staticData.url_registry[result.site_id];\nif (!existing.find(u => u.url === result.article_url)) {\n  existing.push({ url: result.article_url, first_checked: result.checked_at });\n}\n\nreturn [{ json: result }];"
      }
    },
    {
      "id": "respond-qa",
      "name": "Respond with Report",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1700,
        300
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      }
    },
    {
      "id": "qa-results-webhook",
      "name": "QA Results Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        600
      ],
      "parameters": {
        "path": "qa-results",
        "httpMethod": "GET",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "return-qa-results",
      "name": "Return QA Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        600
      ],
      "parameters": {
        "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst qa_results = staticData.qa_results || {};\nconst qa_history = staticData.qa_history || [];\nconst domain_results = staticData.domain_results || {};\nconst portfolio_result = staticData.portfolio_result || null;\nconst suppressed = staticData.suppressed_checks || {};\n\nconst sites = {};\nfor (const [url, result] of Object.entries(qa_results)) {\n  if (!sites[result.site_id]) sites[result.site_id] = { articles: [], avg_score: 0 };\n  sites[result.site_id].articles.push({ url, ...result });\n}\nfor (const site of Object.values(sites)) {\n  site.avg_score = Math.round(site.articles.reduce((s, a) => s + a.score, 0) / site.articles.length);\n}\n\nreturn [{ json: {\n  article_results_by_site: sites,\n  domain_results,\n  portfolio_result,\n  suppressed_checks: suppressed,\n  total_articles_checked: Object.keys(qa_results).length,\n  recent_history: qa_history.slice(-20),\n} }];"
      }
    },
    {
      "id": "respond-qa-results",
      "name": "Respond QA Results",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        800,
        600
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      }
    },
    {
      "id": "qa-sweep-webhook",
      "name": "QA Sweep Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        100
      ],
      "parameters": {
        "path": "qa-sweep",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "domain-webhook",
      "name": "Domain QA Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        700
      ],
      "parameters": {
        "path": "qa-domain",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "domain-resolve",
      "name": "Resolve Domains",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        700
      ],
      "parameters": {
        "jsCode": "const body = $json.body || $json;\nconst SITES = {\n  sinabarimd: { domain: 'sinabarimd.com', role: 'canonical_hub', expected_schema: ['Person', 'ProfilePage'], requires_geo: true, requires_sameas: true },\n  sinabari_net: { domain: 'sinabari.net', role: 'satellite', expected_schema: ['WebSite', 'WebPage'], requires_geo: false, requires_sameas: false },\n  sinabariplasticsurgery: { domain: 'sinabariplasticsurgery.com', role: 'satellite', expected_schema: ['WebSite', 'MedicalWebPage'], requires_geo: false, requires_sameas: false },\n  drsinabari: { domain: 'drsinabari.com', role: 'satellite', expected_schema: ['WebSite', 'WebPage'], requires_geo: false, requires_sameas: false },\n};\n\nlet sites_to_check = [];\nif (body.site_id && SITES[body.site_id]) {\n  sites_to_check.push({ site_id: body.site_id, ...SITES[body.site_id] });\n} else {\n  sites_to_check = Object.entries(SITES).map(([id, s]) => ({ site_id: id, ...s }));\n}\n\nreturn sites_to_check.map(s => ({ json: { ...s, checked_at: new Date().toISOString(), source: body.source || 'operator' } }));"
      }
    },
    {
      "id": "domain-fetch",
      "name": "Fetch Domain Homepage",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        800,
        700
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://' + $json.domain + '/' }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "domain-checks",
      "name": "Run Domain Checks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        700
      ],
      "parameters": {
        "jsCode": "const upstream = $('Resolve Domains').first().json;\nconst site_id = upstream.site_id;\nconst domain = upstream.domain;\nconst role = upstream.role;\nconst expected_schema = upstream.expected_schema;\nconst requires_geo = upstream.requires_geo;\nconst requires_sameas = upstream.requires_sameas;\nconst checked_at = upstream.checked_at;\n\nconst html = typeof $json.data === 'string' ? $json.data : (typeof $json === 'string' ? $json : JSON.stringify($json));\nconst http_ok = html.length > 500;\n\nconst checks = [];\nconst chk = (id, label, passed, detail, severity = 'error') => {\n  checks.push({ id, label, passed, detail, severity });\n};\n\nchk('http_200', 'Homepage loads', http_ok, http_ok ? 'OK' : 'Failed (' + html.length + ' bytes)');\nif (!http_ok) {\n  return [{ json: { qa_type: 'domain', site_id, domain, checked_at, score: 0, grade: 'F', passed: false, checks, failed_checks: checks, warnings: [] } }];\n}\n\nconst titleM = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nchk('title', 'Title tag exists', !!(titleM && titleM[1] && titleM[1].trim()), titleM ? '\"' + titleM[1].trim().slice(0, 70) + '\"' : 'Missing');\n\nconst descM = html.match(/<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']*)[\"']/i);\nchk('meta_desc', 'Meta description exists', !!descM, descM ? descM[1].length + ' chars' : 'Missing');\n\nconst canonM = html.match(/<link[^>]+rel=[\"']canonical[\"'][^>]+href=[\"']([^\"']*)[\"']/i);\nchk('canonical', 'Canonical URL present', !!canonM, canonM ? canonM[1] : 'Missing');\n\nconst ogtM = html.match(/og:type[\"']\\s+content=[\"']([^\"']*)[\"']/i);\nchk('og_type', 'og:type present', !!ogtM, ogtM ? ogtM[1] : 'Missing', 'warning');\n\n// Schema parsing - extract JSON-LD blocks\nconst ldRegex = /type=\"application\\/ld\\+json\"[^>]*>([\\s\\S]*?)<\\/script>/gi;\nlet foundTypes = [];\nlet hasAuthorId = false;\nlet sameAsCount = 0;\nlet personCount = 0;\nlet match;\nwhile ((match = ldRegex.exec(html)) !== null) {\n  try {\n    const parsed = JSON.parse(match[1]);\n    const items = parsed['@graph'] ? parsed['@graph'] : [parsed];\n    for (const item of items) {\n      if (item['@type']) { const t = Array.isArray(item['@type']) ? item['@type'] : [item['@type']]; foundTypes.push(...t); }\n      if (item['@type'] === 'Person') personCount++;\n      if (item['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n      if (item.author && item.author['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n      if (item.sameAs && Array.isArray(item.sameAs)) sameAsCount = item.sameAs.length;\n    }\n  } catch (_) {}\n}\n\nconst hasExpected = expected_schema.every(t => foundTypes.includes(t));\nchk('schema_types', 'Schema has ' + expected_schema.join(' + '), hasExpected, 'Found: [' + foundTypes.join(', ') + ']');\nchk('schema_entity', 'References sinabarimd.com/#sinabari', hasAuthorId, hasAuthorId ? 'OK' : 'Missing');\n\nif (role === 'satellite') {\n  chk('no_person', 'No Person schema on satellite', personCount === 0, personCount ? personCount + ' Person found' : 'OK');\n  chk('no_sameas', 'No sameAs on satellite', sameAsCount === 0, sameAsCount ? sameAsCount + ' sameAs' : 'OK');\n}\nif (requires_sameas) {\n  chk('has_sameas', 'Has sameAs URLs', sameAsCount >= 3, sameAsCount + ' URLs');\n}\nif (requires_geo) {\n  chk('geo_meta', 'Geo meta tags', html.includes('geo.region'), html.includes('geo.region') ? 'OK' : 'Missing');\n}\n\nif (role === 'satellite') {\n  const hasHub = html.includes('sinabarimd.com');\n  chk('hub_link', 'Links to sinabarimd.com', hasHub, hasHub ? 'OK' : 'Missing');\n}\n\nconst noindex = /name=[\"']robots[\"'][^>]*noindex/i.test(html);\nchk('not_noindex', 'Not noindexed', !noindex, noindex ? 'NOINDEX!' : 'OK');\n\nconst hasArticles = /\\/articles\\//i.test(html);\nchk('articles_link', '/articles/ link', hasArticles, hasArticles ? 'OK' : 'Missing', 'warning');\n\n// Check for broken internal links (articles referenced in homepage)\nconst internalLinks = [];\nconst linkRegex = /href=[\"'](\\/articles\\/[^\"']+\\.html)[\"']/gi;\nlet linkMatch;\nwhile ((linkMatch = linkRegex.exec(html)) !== null) {\n  const path = linkMatch[1];\n  if (!internalLinks.includes(path)) internalLinks.push(path);\n}\n\nlet brokenLinks = [];\nfor (const path of internalLinks) {\n  try {\n    const resp = await this.helpers.httpRequest({\n      method: 'HEAD',\n      url: 'https://' + domain + path,\n      returnFullResponse: true,\n      timeout: 5000,\n    });\n    if (resp.statusCode >= 400) brokenLinks.push(path);\n  } catch(e) {\n    brokenLinks.push(path);\n  }\n}\n\nchk('broken_links', 'No broken internal article links', brokenLinks.length === 0,\n  brokenLinks.length === 0 ? internalLinks.length + ' links OK' : 'BROKEN: ' + brokenLinks.join(', '));\n\nconst errors = checks.filter(c => c.severity === 'error');\nconst passedE = errors.filter(c => c.passed).length;\nconst score = errors.length > 0 ? Math.round((passedE / errors.length) * 100) : 100;\nconst grade = score >= 95 ? 'A+' : score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 65 ? 'C' : score >= 50 ? 'D' : 'F';\n\nreturn [{ json: {\n  qa_type: 'domain', site_id, domain, checked_at, source: upstream.source,\n  score, grade, passed: score >= 80, total_checks: checks.length,\n  failed_checks: errors.filter(c => !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  warnings: checks.filter(c => c.severity === 'warning' && !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  checks,\n} }];"
      }
    },
    {
      "id": "domain-store",
      "name": "Store Domain Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1400,
        700
      ],
      "parameters": {
        "jsCode": "const result = $json;\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.domain_results) staticData.domain_results = {};\nstaticData.domain_results[result.site_id] = {\n  domain: result.domain, score: result.score, grade: result.grade,\n  passed: result.passed, checked_at: result.checked_at,\n  failed_checks: result.failed_checks, warnings: result.warnings,\n};\nreturn [{ json: result }];"
      }
    },
    {
      "id": "domain-respond",
      "name": "Respond Domain QA",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1700,
        700
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      }
    },
    {
      "id": "portfolio-webhook",
      "name": "Portfolio QA Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        1000
      ],
      "parameters": {
        "path": "qa-portfolio",
        "httpMethod": "POST",
        "responseMode": "lastNode",
        "options": {}
      }
    },
    {
      "id": "portfolio-checks",
      "name": "Run Portfolio Checks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1700,
        1000
      ],
      "parameters": {
        "jsCode": "const sinabarimd_html = $json.sinabarimd_html || '';\nconst sinabari_net_html = $json.sinabari_net_html || '';\nconst sbps_html = $json.sbps_html || '';\n\nconst checked_at = new Date().toISOString();\nconst checks = [];\nconst chk = (id, label, passed, detail, severity = 'error') => {\n  checks.push({ id, label, passed, detail, severity });\n};\n\nfunction parseSchemas(html) {\n  const types = [];\n  let hasAuthorId = false;\n  let hasSameAs = false;\n  let personCount = 0;\n  const ldRegex = /type=\"application\\/ld\\+json\"[^>]*>([\\s\\S]*?)<\\/script>/gi;\n  let match;\n  while ((match = ldRegex.exec(html)) !== null) {\n    try {\n      const parsed = JSON.parse(match[1]);\n      const items = parsed['@graph'] ? parsed['@graph'] : [parsed];\n      for (const item of items) {\n        if (item['@type']) {\n        const t = Array.isArray(item['@type']) ? item['@type'] : [item['@type']];\n        types.push(...t);\n      }\n        const typeArr = Array.isArray(item['@type']) ? item['@type'] : [item['@type']];\n        if (typeArr.includes('Person')) personCount++;\n        if (item['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n        if (item.author && item.author['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n        if (item.sameAs && Array.isArray(item.sameAs) && item.sameAs.length > 0) hasSameAs = true;\n      }\n    } catch (_) {}\n  }\n  return { types, hasAuthorId, hasSameAs, personCount };\n}\n\nconst sm = parseSchemas(sinabarimd_html);\nconst sn = parseSchemas(sinabari_net_html);\nconst sp = parseSchemas(sbps_html);\n\nchk('canonical_person', 'sinabarimd.com has Person schema', sm.types.includes('Person'), 'Found: [' + sm.types.join(', ') + ']');\nchk('canonical_profile', 'sinabarimd.com has ProfilePage', sm.types.includes('ProfilePage'), 'Found: [' + sm.types.join(', ') + ']');\nchk('sn_no_person', 'sinabari.net has no Person schema', sn.personCount === 0, sn.personCount ? sn.personCount + ' Person found' : 'OK (WebSite)');\nchk('sp_no_person', 'sinabariplasticsurgery.com has no Person schema', sp.personCount === 0, sp.personCount ? sp.personCount + ' Person found' : 'OK (WebSite)');\nchk('sm_entity', 'sinabarimd.com references /#sinabari', sm.hasAuthorId, sm.hasAuthorId ? 'OK' : 'Missing');\nchk('sn_entity', 'sinabari.net references /#sinabari', sn.hasAuthorId, sn.hasAuthorId ? 'OK' : 'Missing');\nchk('sp_entity', 'sinabariplasticsurgery.com references /#sinabari', sp.hasAuthorId, sp.hasAuthorId ? 'OK' : 'Missing');\nchk('sameas_canonical', 'sameAs only on sinabarimd.com', sm.hasSameAs && !sn.hasSameAs && !sp.hasSameAs,\n  (sm.hasSameAs ? 'canonical: yes' : 'canonical: MISSING') + ', ' + (sn.hasSameAs ? 'sinabari.net: HAS' : 'sinabari.net: clean') + ', ' + (sp.hasSameAs ? 'sbps: HAS' : 'sbps: clean'));\n\nconst allDistinct = sm.types.sort().join(',') !== sn.types.sort().join(',') && sm.types.sort().join(',') !== sp.types.sort().join(',') && sn.types.sort().join(',') !== sp.types.sort().join(',');\nchk('distinct_types', 'Each site has distinct schema @types', allDistinct,\n  'sm: [' + sm.types.join(', ') + '] | sn: [' + sn.types.join(', ') + '] | sp: [' + sp.types.join(', ') + ']');\n\nconst snHub = sinabari_net_html.includes('sinabarimd.com');\nconst spHub = sbps_html.includes('sinabarimd.com');\nchk('all_link_hub', 'All satellites link to sinabarimd.com', snHub && spHub, 'sinabari.net: ' + (snHub?'OK':'MISSING') + ' | sbps: ' + (spHub?'OK':'MISSING'));\n\nconst getTitle = (h) => (h.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\nconst titles = [getTitle(sinabarimd_html), getTitle(sinabari_net_html), getTitle(sbps_html)].map(t => t.trim());\nchk('unique_titles', 'All sites have unique titles', new Set(titles).size === 3, titles.join(' | ').slice(0, 120));\n\nchk('all_load', 'All 3 sites return content', sinabarimd_html.length > 500 && sinabari_net_html.length > 500 && sbps_html.length > 500, 'OK');\n\nconst authorRe = /Dr\\.?\\s*Sina\\s+Bari/i;\nchk('consistent_author', 'Dr. Sina Bari on all sites', authorRe.test(sinabarimd_html) && authorRe.test(sinabari_net_html) && authorRe.test(sbps_html), 'OK', 'warning');\n\nconst errors = checks.filter(c => c.severity === 'error');\nconst passedE = errors.filter(c => c.passed).length;\nconst score = errors.length > 0 ? Math.round((passedE / errors.length) * 100) : 100;\nconst grade = score >= 95 ? 'A+' : score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 65 ? 'C' : score >= 50 ? 'D' : 'F';\n\nreturn [{ json: {\n  qa_type: 'portfolio', checked_at, score, grade, passed: score >= 80,\n  total_checks: checks.length,\n  failed_checks: errors.filter(c => !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  warnings: checks.filter(c => c.severity === 'warning' && !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  checks,\n} }];"
      }
    },
    {
      "id": "portfolio-store",
      "name": "Store Portfolio Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        1000
      ],
      "parameters": {
        "jsCode": "const result = $json;\nconst staticData = $getWorkflowStaticData('global');\nstaticData.portfolio_result = {\n  score: result.score, grade: result.grade, passed: result.passed,\n  checked_at: result.checked_at,\n  failed_checks: result.failed_checks, warnings: result.warnings, checks: result.checks,\n};\nreturn [{ json: result }];"
      }
    },
    {
      "id": "pf-fetch-1",
      "name": "PF Fetch sinabarimd",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        500,
        1000
      ],
      "parameters": {
        "method": "GET",
        "url": "https://sinabarimd.com/",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "pf-save-1",
      "name": "PF Save sinabarimd",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        1000
      ],
      "parameters": {
        "jsCode": "return [{ json: { sinabarimd_html: $json.data || '' } }];"
      }
    },
    {
      "id": "pf-fetch-2",
      "name": "PF Fetch sinabari.net",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        900,
        1000
      ],
      "parameters": {
        "method": "GET",
        "url": "https://sinabari.net/",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "pf-save-2",
      "name": "PF Save sinabari.net",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        1000
      ],
      "parameters": {
        "jsCode": "const prev = $('PF Save sinabarimd').first().json;\nreturn [{ json: { ...prev, sinabari_net_html: $json.data || '' } }];"
      }
    },
    {
      "id": "pf-fetch-3",
      "name": "PF Fetch sbps",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1300,
        1000
      ],
      "parameters": {
        "method": "GET",
        "url": "https://sinabariplasticsurgery.com/",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "pf-save-3",
      "name": "PF Save sbps",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1500,
        1000
      ],
      "parameters": {
        "jsCode": "const prev = $('PF Save sinabari.net').first().json;\nreturn [{ json: { ...prev, sbps_html: $json.data || '' } }];"
      }
    },
    {
      "id": "suppress-webhook",
      "name": "Suppress Check Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        1400
      ],
      "parameters": {
        "path": "qa-suppress",
        "httpMethod": "POST",
        "responseMode": "responseNode",
        "options": {}
      }
    },
    {
      "id": "suppress-code",
      "name": "Suppress Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        1400
      ],
      "parameters": {
        "jsCode": "const body = $json.body || $json;\nconst { qa_type, identifier, check_id, action } = body;\n// action: \"suppress\" or \"unsuppress\"\n\nif (!check_id) return [{ json: { error: true, message: 'Missing check_id' } }];\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.suppressed_checks) staticData.suppressed_checks = {};\n\nconst key = `${qa_type || 'all'}:${identifier || 'all'}:${check_id}`;\n\nif (action === 'unsuppress') {\n  delete staticData.suppressed_checks[key];\n  return [{ json: { success: true, action: 'unsuppressed', key } }];\n}\n\nstaticData.suppressed_checks[key] = {\n  suppressed_at: new Date().toISOString(),\n  qa_type: qa_type || 'all',\n  identifier: identifier || 'all',\n  check_id,\n};\n\nreturn [{ json: { success: true, action: 'suppressed', key } }];"
      }
    },
    {
      "id": "suppress-respond",
      "name": "Respond Suppress",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        800,
        1400
      ],
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      }
    }
  ],
  "connections": {
    "QA Check Webhook": {
      "main": [
        [
          {
            "node": "Resolve URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "QA Sweep Webhook": {
      "main": [
        [
          {
            "node": "Resolve URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve URLs": {
      "main": [
        [
          {
            "node": "Fetch Article Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Article Page": {
      "main": [
        [
          {
            "node": "Run SEO Checks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run SEO Checks": {
      "main": [
        [
          {
            "node": "Store QA Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store QA Results": {
      "main": [
        [
          {
            "node": "Respond with Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "QA Results Webhook": {
      "main": [
        [
          {
            "node": "Return QA Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Return QA Results": {
      "main": [
        [
          {
            "node": "Respond QA Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Domain QA Webhook": {
      "main": [
        [
          {
            "node": "Resolve Domains",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Domains": {
      "main": [
        [
          {
            "node": "Fetch Domain Homepage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Domain Homepage": {
      "main": [
        [
          {
            "node": "Run Domain Checks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Domain Checks": {
      "main": [
        [
          {
            "node": "Store Domain Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store Domain Results": {
      "main": [
        [
          {
            "node": "Respond Domain QA",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Portfolio QA Webhook": {
      "main": [
        [
          {
            "node": "PF Fetch sinabarimd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Portfolio Checks": {
      "main": [
        [
          {
            "node": "Store Portfolio Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Fetch sinabarimd": {
      "main": [
        [
          {
            "node": "PF Save sinabarimd",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Save sinabarimd": {
      "main": [
        [
          {
            "node": "PF Fetch sinabari.net",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Fetch sinabari.net": {
      "main": [
        [
          {
            "node": "PF Save sinabari.net",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Save sinabari.net": {
      "main": [
        [
          {
            "node": "PF Fetch sbps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Fetch sbps": {
      "main": [
        [
          {
            "node": "PF Save sbps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PF Save sbps": {
      "main": [
        [
          {
            "node": "Run Portfolio Checks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Suppress Check Webhook": {
      "main": [
        [
          {
            "node": "Suppress Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Suppress Check": {
      "main": [
        [
          {
            "node": "Respond Suppress",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false
  },
  "meta": null,
  "activeVersionId": "5876660f-1427-4bb4-8cc9-7483bd7245b3",
  "versionCounter": 4,
  "triggerCount": 6,
  "shared": [
    {
      "updatedAt": "2026-04-27T18:53:42.201Z",
      "createdAt": "2026-04-27T18:53:42.201Z",
      "role": "workflow:owner",
      "workflowId": "3RYvkOtuOInfaiuZ",
      "projectId": "9sJSA5GTLSjQcRNk",
      "project": {
        "updatedAt": "2026-03-20T18:09:16.655Z",
        "createdAt": "2026-03-20T00:15:30.157Z",
        "id": "9sJSA5GTLSjQcRNk",
        "name": "Sina Bari <YOUR_EMAIL@example.com>",
        "type": "personal",
        "icon": null,
        "description": null,
        "creatorId": "d84a1587-61fd-429c-9ea6-1d21d8267ea9"
      }
    }
  ],
  "tags": [],
  "activeVersion": {
    "updatedAt": "2026-04-27T18:53:42.206Z",
    "createdAt": "2026-04-27T18:53:42.206Z",
    "versionId": "5876660f-1427-4bb4-8cc9-7483bd7245b3",
    "workflowId": "3RYvkOtuOInfaiuZ",
    "nodes": [
      {
        "id": "qa-check-webhook",
        "name": "QA Check Webhook",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          200,
          300
        ],
        "webhookId": "qa-check",
        "parameters": {
          "path": "qa-check",
          "httpMethod": "POST",
          "responseMode": "responseNode",
          "options": {}
        }
      },
      {
        "id": "resolve-urls",
        "name": "Resolve URLs",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          500,
          300
        ],
        "parameters": {
          "jsCode": "const body = $json.body || $json;\nconst source = body.source || 'operator';\n\nconst SITE_MAP = {\n  sinabarimd: { domain: 'sinabarimd.com', requires_hub_link: false },\n  sinabari_net: { domain: 'sinabari.net', requires_hub_link: true },\n  sinabariplasticsurgery: { domain: 'sinabariplasticsurgery.com', requires_hub_link: true },\n};\n\nlet urls_to_check = [];\n\nif (body.article_url) {\n  urls_to_check.push({\n    site_id: body.site_id,\n    article_url: body.article_url,\n    domain: SITE_MAP[body.site_id]?.domain || body.site_id,\n    requires_hub_link: SITE_MAP[body.site_id]?.requires_hub_link ?? true,\n  });\n} else if (body.article_urls && Array.isArray(body.article_urls)) {\n  for (const entry of body.article_urls) {\n    const sid = entry.site_id || body.site_id;\n    urls_to_check.push({\n      site_id: sid,\n      article_url: entry.article_url || entry.url,\n      domain: SITE_MAP[sid]?.domain,\n      requires_hub_link: SITE_MAP[sid]?.requires_hub_link ?? true,\n    });\n  }\n} else {\n  // Sweep mode\n  const staticData = $getWorkflowStaticData('global');\n  const registry = staticData.url_registry || {};\n  for (const [site_id, articles] of Object.entries(registry)) {\n    for (const article of articles) {\n      urls_to_check.push({\n        site_id,\n        article_url: article.url,\n        domain: SITE_MAP[site_id]?.domain,\n        requires_hub_link: SITE_MAP[site_id]?.requires_hub_link ?? true,\n      });\n    }\n  }\n}\n\nif (urls_to_check.length === 0) {\n  return [{ json: { error: true, message: 'No URLs to check.' } }];\n}\n\nreturn urls_to_check.map(u => ({ json: { ...u, source, checked_at: new Date().toISOString() } }));"
        }
      },
      {
        "id": "fetch-article",
        "name": "Fetch Article Page",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [
          800,
          300
        ],
        "parameters": {
          "method": "GET",
          "url": "={{ $json.article_url }}",
          "options": {
            "response": {
              "response": {
                "responseFormat": "text"
              }
            },
            "redirect": {
              "redirect": {
                "followRedirects": true
              }
            },
            "timeout": 15000
          }
        },
        "onError": "continueRegularOutput"
      },
      {
        "id": "run-seo-checks",
        "name": "Run SEO Checks",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          1100,
          300
        ],
        "parameters": {
          "jsCode": "const upstream = $('Resolve URLs').first().json;\nconst site_id = upstream.site_id;\nconst article_url = upstream.article_url;\nconst domain = upstream.domain;\nconst requires_hub_link = upstream.requires_hub_link;\nconst checked_at = upstream.checked_at;\nconst source = upstream.source;\n\nconst html = typeof $json.data === 'string' ? $json.data : (typeof $json === 'string' ? $json : '');\nconst http_ok = html.length > 100;\n\nconst checks = [];\nconst AUTHOR_NAME = 'Dr. Sina Bari, MD';\nconst AUTHOR_ID = 'https://sinabarimd.com/#sinabari';\n\nconst chk = (id, label, passed, detail, severity = 'error') => {\n  checks.push({ id, label, passed, detail, severity });\n};\n\n// 1. Page loads\nchk('http_200', 'Page loads successfully', http_ok, http_ok ? 'OK' : 'Failed to fetch page content');\n\nif (!http_ok) {\n  return [{ json: { site_id, article_url, domain, checked_at, source, score: 0, grade: 'F', passed: false, total_checks: 1, checks, failed_checks: checks.filter(c => !c.passed), warnings: [] } }];\n}\n\n// 2. Title\nconst titleM = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nconst title = titleM?.[1]?.trim() || '';\nchk('title', 'Title tag exists', title.length > 0, title.length ? '\"' + title.slice(0,70) + '\"' : 'Missing');\n\n// 3. Meta description\nconst descM = html.match(/<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+name=[\"']description[\"']/i);\nchk('meta_desc', 'Meta description exists', !!descM, descM ? descM[1].length + ' chars' : 'Missing');\n\n// 4. Canonical\nconst canonM = html.match(/<link\\s+rel=[\"']canonical[\"']\\s+href=[\"']([^\"']*)[\"']/i) || html.match(/<link\\s+href=[\"']([^\"']*)[\"']\\s+rel=[\"']canonical[\"']/i);\nconst canon = canonM?.[1] || '';\nchk('canonical', 'Canonical URL matches page', canon === article_url || canon === article_url.replace(/\\/$/, ''), canon ? 'canonical=' + canon : 'Missing');\n\n// 5. og:type\nconst ogtM = html.match(/<meta\\s+property=[\"']og:type[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+property=[\"']og:type[\"']/i);\nchk('og_type', 'og:type is article', ogtM?.[1]?.toLowerCase() === 'article', ogtM ? ogtM[1] : 'Missing');\n\n// 6. og:site_name\nconst ogsM = html.match(/<meta\\s+property=[\"']og:site_name[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+property=[\"']og:site_name[\"']/i);\nchk('og_site_name', 'og:site_name exists', !!ogsM, ogsM ? ogsM[1] : 'Missing');\n\n// 7. article:published_time\nconst aptM = html.match(/article:published_time[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/content=[\"']([^\"']*)[\"']\\s+property=[\"']article:published_time[\"']/i);\nchk('pub_time', 'article:published_time exists', !!aptM, aptM ? aptM[1] : 'Missing');\n\n// 8. twitter:card\nconst twM = html.match(/twitter:card[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/content=[\"']([^\"']*)[\"']\\s+name=[\"']twitter:card[\"']/i);\nchk('twitter', 'twitter:card exists', !!twM, twM ? twM[1] : 'Missing');\n\n// 9. meta author\nconst authM = html.match(/<meta\\s+name=[\"']author[\"']\\s+content=[\"']([^\"']*)[\"']/i) || html.match(/<meta\\s+content=[\"']([^\"']*)[\"']\\s+name=[\"']author[\"']/i);\nchk('meta_author', 'Meta author is Dr. Sina Bari, MD', authM?.[1] === AUTHOR_NAME, authM ? authM[1] : 'Missing');\n\n// 10-11. Schema checks\nconst ldBlocks = html.match(/<script[^>]+application\\/ld\\+json[^>]*>([\\s\\S]*?)<\\/script>/gi) || [];\nlet hasArticleType = false, hasAuthorId = false, hasFaq = false, schemaType = '';\nfor (const block of ldBlocks) {\n  try {\n    const inner = block.match(/>([\\s\\S]*?)<\\/script>/i)?.[1] || '{}';\n    const parsed = JSON.parse(inner);\n    const items = parsed['@graph'] ? parsed['@graph'] : [parsed];\n    for (const item of items) {\n      const t = item['@type'];\n      if (t === 'Article' || t === 'MedicalWebPage') { hasArticleType = true; schemaType = t; }\n      if (t === 'FAQPage') hasFaq = true;\n      if (item.author?.['@id'] === AUTHOR_ID) hasAuthorId = true;\n    }\n  } catch (_) {}\n}\nchk('schema_type', 'Schema Article/MedicalWebPage exists', hasArticleType, hasArticleType ? schemaType : 'Missing');\nchk('schema_author_id', 'Schema author @id = sinabarimd.com/#sinabari', hasAuthorId, hasAuthorId ? 'OK' : 'Missing @id');\n\n// 12. FAQPage (warning)\nchk('faq_schema', 'FAQPage schema exists', hasFaq, hasFaq ? 'OK' : 'No FAQPage \u2014 AEO opportunity missed', 'warning');\n\n// 13. Author byline\nconst hasByline = html.includes('<aside') && html.split('<aside')[1]?.includes('sinabarimd.com/about');\nchk('author_byline', 'Author byline with sinabarimd.com/about link', hasByline, hasByline ? 'OK' : 'Missing byline block');\n\n// 14. Hub backlink (satellite sites only)\nif (requires_hub_link) {\n  const hasHub = /href=[\"']https?:\\/\\/(www\\.)?sinabarimd\\.com/i.test(html);\n  chk('hub_backlink', 'Link to sinabarimd.com exists', hasHub, hasHub ? 'OK' : 'No sinabarimd.com link');\n}\n\n// 15. Articles nav link (warning)\nchk('articles_nav', 'Nav has /articles/ link', html.includes('/articles/'), html.includes('/articles/') ? 'OK' : 'Missing', 'warning');\n\n\n// 16. Outbound source links (warning)\nconst extLinkRe = /href=[\"']https?:\\/\\/(?!sinabarimd\\.com|sinabari\\.net|sinabariplasticsurgery\\.com|drsinabari\\.com)[^\"']+[\"']/gi;\nconst extLinks = (html || '').match(extLinkRe) || [];\nchk('outbound_links', 'Has outbound authority links', extLinks.length > 0, extLinks.length ? extLinks.length + ' external links' : 'No outbound links \u2014 add 2-3 for E-E-A-T', 'warning');\n\n// Score (errors only, warnings excluded)\nconst errors = checks.filter(c => c.severity === 'error');\nconst passedErrors = errors.filter(c => c.passed).length;\nconst score = errors.length > 0 ? Math.round((passedErrors / errors.length) * 100) : 100;\nconst grade = score >= 95 ? 'A+' : score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 65 ? 'C' : score >= 50 ? 'D' : 'F';\n\nreturn [{ json: {\n  site_id, article_url, domain, checked_at, source, score, grade,\n  passed: score >= 80,\n  total_checks: checks.length,\n  failed_checks: errors.filter(c => !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  warnings: checks.filter(c => c.severity === 'warning' && !c.passed).map(c => ({ id: c.id, label: c.label, detail: c.detail })),\n  checks,\n} }];"
        }
      },
      {
        "id": "store-qa",
        "name": "Store QA Results",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          1400,
          300
        ],
        "parameters": {
          "jsCode": "const result = $json;\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.qa_results) staticData.qa_results = {};\nif (!staticData.qa_history) staticData.qa_history = [];\nif (!staticData.url_registry) staticData.url_registry = {};\n\n// Guard: don't store homepage URLs as article results\nconst url = result.article_url || '';\nif (url.endsWith('/') || !url.includes('/articles/')) {\n  // This is a homepage or non-article URL \u2014 skip article storage\n  return [{ json: { ...result, skipped: true, reason: 'Not an article URL \u2014 use domain QA for homepages' } }];\n}\n\nstaticData.qa_results[result.article_url] = {\n  site_id: result.site_id,\n  score: result.score,\n  grade: result.grade,\n  passed: result.passed,\n  checked_at: result.checked_at,\n  failed_checks: result.failed_checks,\n  warnings: result.warnings,\n};\n\nstaticData.qa_history.push({\n  article_url: result.article_url,\n  site_id: result.site_id,\n  score: result.score,\n  grade: result.grade,\n  checked_at: result.checked_at,\n});\nif (staticData.qa_history.length > 100) staticData.qa_history = staticData.qa_history.slice(-100);\n\nif (!staticData.url_registry[result.site_id]) staticData.url_registry[result.site_id] = [];\nconst existing = staticData.url_registry[result.site_id];\nif (!existing.find(u => u.url === result.article_url)) {\n  existing.push({ url: result.article_url, first_checked: result.checked_at });\n}\n\nreturn [{ json: result }];"
        }
      },
      {
        "id": "respond-qa",
        "name": "Respond with Report",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          1700,
          300
        ],
        "parameters": {
          "respondWith": "json",
          "responseBody": "={{ $json }}"
        }
      },
      {
        "id": "qa-results-webhook",
        "name": "QA Results Webhook",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          200,
          600
        ],
        "webhookId": "qa-results",
        "parameters": {
          "path": "qa-results",
          "httpMethod": "GET",
          "responseMode": "responseNode",
          "options": {}
        }
      },
      {
        "id": "return-qa-results",
        "name": "Return QA Results",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          500,
          600
        ],
        "parameters": {
          "jsCode": "const staticData = $getWorkflowStaticData('global');\nconst qa_results = staticData.qa_results || {};\nconst qa_history = staticData.qa_history || [];\nconst domain_results = staticData.domain_results || {};\nconst portfolio_result = staticData.portfolio_result || null;\nconst suppressed = staticData.suppressed_checks || {};\n\nconst sites = {};\nfor (const [url, result] of Object.entries(qa_results)) {\n  if (!sites[result.site_id]) sites[result.site_id] = { articles: [], avg_score: 0 };\n  sites[result.site_id].articles.push({ url, ...result });\n}\nfor (const site of Object.values(sites)) {\n  site.avg_score = Math.round(site.articles.reduce((s, a) => s + a.score, 0) / site.articles.length);\n}\n\nreturn [{ json: {\n  article_results_by_site: sites,\n  domain_results,\n  portfolio_result,\n  suppressed_checks: suppressed,\n  total_articles_checked: Object.keys(qa_results).length,\n  recent_history: qa_history.slice(-20),\n} }];"
        }
      },
      {
        "id": "respond-qa-results",
        "name": "Respond QA Results",
        "type": "n8n-nodes-base.respondToWebhook",
        "typeVersion": 1.1,
        "position": [
          800,
          600
        ],
        "parameters": {
          "respondWith": "json",
          "responseBody": "={{ $json }}"
        }
      },
      {
        "id": "qa-sweep-webhook",
        "name": "QA Sweep Webhook",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          200,
          100
        ],
        "webhookId": "qa-sweep",
        "parameters": {
          "path": "qa-sweep",
          "httpMethod": "POST",
          "responseMode": "responseNode",
          "options": {}
        }
      },
      {
        "id": "domain-webhook",
        "name": "Domain QA Webhook",
        "type": "n8n-nodes-base.webhook",
        "typeVersion": 2,
        "position": [
          200,
          700
        ],
        "webhookId": "qa-domain",
        "parameters": {
          "path": "qa-domain",
          "httpMethod": "POST",
          "responseMode": "responseNode",
          "options": {}
        }
      },
      {
        "id": "domain-resolve",
        "name": "Resolve Domains",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          500,
          700
        ],
        "parameters": {
          "jsCode": "const body = $json.body || $json;\nconst SITES = {\n  sinabarimd: { domain: 'sinabarimd.com', role: 'canonical_hub', expected_schema: ['Person', 'ProfilePage'], requires_geo: true, requires_sameas: true },\n  sinabari_net: { domain: 'sinabari.net', role: 'satellite', expected_schema: ['WebSite', 'WebPage'], requires_geo: false, requires_sameas: false },\n  sinabariplasticsurgery: { domain: 'sinabariplasticsurgery.com', role: 'satellite', expected_schema: ['WebSite', 'MedicalWebPage'], requires_geo: false, requires_sameas: false },\n  drsinabari: { domain: 'drsinabari.com', role: 'satellite', expected_schema: ['WebSite', 'WebPage'], requires_geo: false, requires_sameas: false },\n};\n\nlet sites_to_check = [];\nif (body.site_id && SITES[body.site_id]) {\n  sites_to_check.push({ site_id: body.site_id, ...SITES[body.site_id] });\n} else {\n  sites_to_check = Object.entries(SITES).map(([id, s]) => ({ site_id: id, ...s }));\n}\n\nreturn sites_to_check.map(s => ({ json: { ...s, checked_at: new Date().toISOString(), source: body.source || 'operator' } }));"
        }
      },
      {
        "id": "domain-fetch",
        "name": "Fetch Domain Homepage",
        "type": "n8n-nodes-base.httpRequest",
        "typeVersion": 4.2,
        "position": [
          800,
          700
        ],
        "parameters": {
          "method": "GET",
          "url": "={{ 'https://' + $json.domain + '/' }}",
          "options": {
            "response": {
              "response": {
                "responseFormat": "text"
              }
            },
            "timeout": 15000
          }
        },
        "onError": "continueRegularOutput"
      },
      {
        "id": "domain-checks",
        "name": "Run Domain Checks",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [
          1100,
          700
        ],
        "parameters": {
          "jsCode": "const upstream = $('Resolve Domains').first().json;\nconst site_id = upstream.site_id;\nconst domain = upstream.domain;\nconst role = upstream.role;\nconst expected_schema = upstream.expected_schema;\nconst requires_geo = upstream.requires_geo;\nconst requires_sameas = upstream.requires_sameas;\nconst checked_at = upstream.checked_at;\n\nconst html = typeof $json.data === 'string' ? $json.data : (typeof $json === 'string' ? $json : JSON.stringify($json));\nconst http_ok = html.length > 500;\n\nconst checks = [];\nconst chk = (id, label, passed, detail, severity = 'error') => {\n  checks.push({ id, label, passed, detail, severity });\n};\n\nchk('http_200', 'Homepage loads', http_ok, http_ok ? 'OK' : 'Failed (' + html.length + ' bytes)');\nif (!http_ok) {\n  return [{ json: { qa_type: 'domain', site_id, domain, checked_at, score: 0, grade: 'F', passed: false, checks, failed_checks: checks, warnings: [] } }];\n}\n\nconst titleM = html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i);\nchk('title', 'Title tag exists', !!(titleM && titleM[1] && titleM[1].trim()), titleM ? '\"' + titleM[1].trim().slice(0, 70) + '\"' : 'Missing');\n\nconst descM = html.match(/<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']*)[\"']/i);\nchk('meta_desc', 'Meta description exists', !!descM, descM ? descM[1].length + ' chars' : 'Missing');\n\nconst canonM = html.match(/<link[^>]+rel=[\"']canonical[\"'][^>]+href=[\"']([^\"']*)[\"']/i);\nchk('canonical', 'Canonical URL present', !!canonM, canonM ? canonM[1] : 'Missing');\n\nconst ogtM = html.match(/og:type[\"']\\s+content=[\"']([^\"']*)[\"']/i);\nchk('og_type', 'og:type present', !!ogtM, ogtM ? ogtM[1] : 'Missing', 'warning');\n\n// Schema parsing - extract JSON-LD blocks\nconst ldRegex = /type=\"application\\/ld\\+json\"[^>]*>([\\s\\S]*?)<\\/script>/gi;\nlet foundTypes = [];\nlet hasAuthorId = false;\nlet sameAsCount = 0;\nlet personCount = 0;\nlet match;\nwhile ((match = ldRegex.exec(html)) !== null) {\n  try {\n    const parsed = JSON.parse(match[1]);\n    const items = parsed['@graph'] ? parsed['@graph'] : [parsed];\n    for (const item of items) {\n      if (item['@type']) { const t = Array.isArray(item['@type']) ? item['@type'] : [item['@type']]; foundTypes.push(...t); }\n      if (item['@type'] === 'Person') personCount++;\n      if (item['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n      if (item.author && item.author['@id'] === 'https://sinabarimd.com/#sinabari') hasAuthorId = true;\n      if (item.sameAs && Array.isArray(item.sameAs)) sameAsCount = item.sameAs.length;\n    }\n  } catch (_) {}\n}\n\nconst hasExpected = expected_schema.every(t => foundTypes.includes(t));\nchk('schema_types', 'Schema has ' + expected_schema.join(' + '), hasExpected, 'Found: [' + foundTypes.join(', ') + ']');\nchk('schema_entity', 'References sinabarimd.com/#sinabari', hasAuthorId, hasAuthorId ? 'OK' : 'Missing');\n\nif (role === 'satellite') {\n  chk('no_person', 'No Person schema on satellite', personCount === 0, personCount ? personCount + ' Person found' : 'OK');\n  chk('no_sameas', 'No sameAs on satellite', sameAsCount === 0, sameAsCount ? sameAsCount + ' sameAs' : 'OK');\n}\nif (requires_sameas) {\n  chk('has_sameas', 'Has sameAs URLs', sameAsCount >= 3, sameAsCount + ' URLs');\n}\nif (requires_geo) {\n  chk('geo_meta', 'Geo meta tags', html.includes('geo.region'), html.includes('geo.region') ? 'OK' : 'Missing');\n}\n\nif (role === 'satellite') {\n  const hasHub = html.includes('sinabarimd.com');\n  chk('hub_link', 'Links to sinabarimd.com', hasHub, hasHub ? 'OK' : 'Missing');\n}\n\nconst noindex = /name=[\"']robots[\"'][^>]*noindex/i.test(html);\nchk('not_noindex', 'Not noindexed', !noindex, noindex ? 'NOINDEX!' : 'OK');\n\nconst hasArticles = /\\/articles\\//i.test(html);\nchk('articles_link', '/articles/ link', hasArticles, hasArticles ? 'OK' : 'Missing', 'warning');\n\n// Check for broken internal links (articles referenced in homepage)\nconst internalLinks = [];\nconst linkRegex = /href=[\"'](\\/articles\\/[^\"']+\\.html)[\"']/gi;\nlet linkMatch;\nwhile ((linkMatch = linkRegex.exec(html)) !== null) {\n  const path = linkMatch[1];\n  if (!internalLinks.includes(path)) internalLinks.push(path);\n}\n\nlet brokenLinks = [];\nfor (const path of internalLinks) {\n  try {\n    const resp = await this.helpers.httpRequest({\n      method: 'HEAD',\n      url: 'https://' + domain + path,\n      returnFullResponse: true,\n      timeout: 5000,\n    });\n    if (re
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

This workflow delivers automated SEO quality assurance for your website content, ensuring articles meet best practices for search engine performance and user engagement. It is designed for content managers, SEO specialists, and digital marketers who need to maintain high standards without manual reviews. Upon receiving a URL via webhook, the system fetches the page content and runs comprehensive checks for elements like meta tags, headings, and keyword density, generating an instant report to highlight issues and recommendations.

Use this workflow when scaling content production and integrating SEO validation into your publishing pipeline, such as after drafting new articles. Avoid it for real-time monitoring of live sites, where dedicated tools like Google Search Console are more suitable. Common variations include adapting the checks for e-commerce product pages or adding notifications via email integrations for team alerts.

About this workflow

Reputation Engine — SEO QA Agent. Uses httpRequest. Webhook trigger; 28 nodes.

Source: https://github.com/sinabarimd/reputation-engine/blob/main/workflows/seo-qa-agent.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Jigsaw API key for image processing, I use this as a gatekeeper/second pair of eyes. LINK to their website https://jigsawstack.com/ SECOND A postgress DATABASE (I use Supabase) LlamaCloud for the pars

HTTP Request, Postgres, Stop And Error +2
AI & RAG

Whatsapp Multi Agent System optimized copy 2.0. Uses airtable, httpRequest, errorTrigger. Webhook trigger; 44 nodes.

Airtable, HTTP Request, Error Trigger
AI & RAG

Invoice Agent. Uses httpRequest, emailSend. Webhook trigger; 29 nodes.

HTTP Request, Email Send
AI & RAG

This workflow handles incoming voice calls or audio messages, transcribes them using Whisper (OpenAI) or ElevenLabs, extracts booking intent and preferred time slots using AI, checks availability on C

HTTP Request
AI & RAG

Local AI Agent (HTTP-based). Uses telegram, httpRequest. Webhook trigger; 24 nodes.

Telegram, HTTP Request