{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "ef9a086f-9fe1-4a9c-81ad-735d4fedb43c",
      "name": "Wait",
      "type": "n8n-nodes-base.wait",
      "position": [
        1088,
        4656
      ],
      "parameters": {
        "unit": "minutes",
        "amount": 2
      },
      "typeVersion": 1.1
    },
    {
      "id": "45942447-fbef-46a1-9438-3b3cd0c25480",
      "name": "Wait1",
      "type": "n8n-nodes-base.wait",
      "position": [
        416,
        4560
      ],
      "parameters": {},
      "typeVersion": 1.1
    },
    {
      "id": "305220ed-6098-4df7-8f41-4c65a7b3bafc",
      "name": "Compression",
      "type": "n8n-nodes-base.compression",
      "position": [
        1728,
        4368
      ],
      "parameters": {},
      "typeVersion": 1.1
    },
    {
      "id": "e355f459-1bf2-476f-a000-7718740b53b5",
      "name": "PSI Parser",
      "type": "n8n-nodes-base.code",
      "position": [
        2480,
        4256
      ],
      "parameters": {
        "jsCode": "// ============================================================\n// PSI EXTRACTOR & MERGER \u2014 Production v1.0\n// Node name in n8n: \"PSI Extractor\"\n// Place AFTER \"Page Speed Insights\" HTTP node\n//       BEFORE \"Report Builder2\"\n//\n// This node:\n//  1. Reads the PSI API response from the HTTP node (current input)\n//  2. Reads the audit JSON from \"SEO Audit Parser1\" node by reference\n//  3. Extracts all key PSI metrics, scores, and opportunities\n//  4. Merges PSI data INTO the audit JSON\n//  5. Outputs a single complete audit+psi object for the Report Builder\n//\n// CWV Thresholds (Google Official):\n//  LCP:  Good < 2500ms | Needs Improvement 2500-4000ms | Poor > 4000ms\n//  CLS:  Good < 0.1    | Needs Improvement 0.1-0.25    | Poor > 0.25\n//  TBT:  Good < 200ms  | Needs Improvement 200-600ms   | Poor > 600ms\n//  FCP:  Good < 1800ms | Needs Improvement 1800-3000ms | Poor > 3000ms\n//  TTFB: Good < 800ms  | Needs Improvement 800-1800ms  | Poor > 1800ms\n//  SI:   Good < 3400ms | Needs Improvement 3400-5800ms | Poor > 5800ms\n// ============================================================\n\n// Get PSI response from HTTP node (current node input)\nconst httpResponse = $input.first().json;\n\n// Handle both n8n wrapper format {body, headers, statusCode}\n// and direct response format\nconst psiBody = httpResponse.body || httpResponse;\nconst lr      = psiBody.lighthouseResult || {};\nconst audits  = lr.audits || {};\nconst cats    = lr.categories || {};\nconst loadExp = psiBody.loadingExperience || {};\nconst cfgSettings = lr.configSettings || {};\n\n// Get audit JSON from parser node (by node reference)\n// IMPORTANT: change \"SEO Audit Parser\" if your node has a different name\nlet audit;\ntry {\n  audit = $('SEO Audit Parser').first().json;\n} catch (e) {\n  throw new Error('Cannot find SEO Audit Parser1 output. Make sure the parser node is named exactly \"SEO Audit Parser1\" and has run successfully.');\n}\n\n// \u2500\u2500 HELPER FUNCTIONS \u2500\u2500\nconst safeScore = (score) => score !== null && score !== undefined ? Math.round(score * 100) : null;\nconst safeMs    = (val) => val ? Math.round(val) : 0;\n\n// CWV threshold classifier\nfunction cwvStatus(metric, value) {\n  const thresholds = {\n    lcp:  [2500, 4000],\n    cls:  [0.1, 0.25],\n    tbt:  [200, 600],\n    fcp:  [1800, 3000],\n    ttfb: [800, 1800],\n    si:   [3400, 5800],\n    tti:  [3800, 7300],\n    fid:  [100, 300],\n  };\n  const t = thresholds[metric];\n  if (!t || value === null || value === undefined) return 'unknown';\n  if (value <= t[0]) return 'good';\n  if (value <= t[1]) return 'needs_improvement';\n  return 'poor';\n}\n\n// \u2500\u2500 EXTRACT METRICS \u2500\u2500\nconst metricsItems = audits.metrics?.details?.items || [{}];\nconst m = metricsItems[0] || {};\n\n// Core Web Vitals (Lab data from Lighthouse)\nconst lcp_ms   = safeMs(m.largestContentfulPaint);\nconst fcp_ms   = safeMs(m.firstContentfulPaint);\nconst tbt_ms   = safeMs(m.totalBlockingTime);\nconst cls_val  = parseFloat((m.cumulativeLayoutShift || 0).toFixed(4));\nconst si_ms    = safeMs(m.speedIndex);\nconst tti_ms   = safeMs(m.interactive);\nconst ttfb_ms  = safeMs(m.timeToFirstByte);\n\n// \u2500\u2500 EXTRACT FIELD DATA (CrUX) IF AVAILABLE \u2500\u2500\nconst fieldMetrics = loadExp.metrics || {};\nconst fieldLCP = fieldMetrics.LARGEST_CONTENTFUL_PAINT_MS;\nconst fieldFID = fieldMetrics.FIRST_INPUT_DELAY_MS;\nconst fieldCLS = fieldMetrics.CUMULATIVE_LAYOUT_SHIFT_SCORE;\nconst fieldINP = fieldMetrics.INTERACTION_TO_NEXT_PAINT;\nconst fieldFCP = fieldMetrics.FIRST_CONTENTFUL_PAINT_MS;\nconst fieldTTFB = fieldMetrics.EXPERIMENTAL_TIME_TO_FIRST_BYTE || fieldMetrics.TIME_TO_FIRST_BYTE_MS;\n\n// \u2500\u2500 EXTRACT OPPORTUNITIES \u2500\u2500\n// Unused JS\nconst unusedJsAudit  = audits['unused-javascript'] || {};\nconst unusedCssAudit = audits['unused-css-rules']  || {};\nconst renderBlkAudit = audits['render-blocking-insight'] || audits['render-blocking-resources'] || {};\nconst cacheAudit     = audits['cache-insight'] || audits['uses-long-cache-ttl'] || {};\nconst largeImages    = audits['uses-responsive-images'] || audits['uses-optimized-images'] || {};\nconst unusedImages   = audits['offscreen-images'] || {};\nconst textCompress   = audits['uses-text-compression'] || {};\nconst webpImages     = audits['uses-webp-images'] || {};\n\n// Opportunity savings\nconst totalOpportunitySavingsMs = [\n  unusedJsAudit.metricSavings?.LCP || 0,\n  unusedCssAudit.metricSavings?.LCP || 0,\n  renderBlkAudit.numericValue || 0,\n].reduce((a, b) => a + b, 0);\n\n// \u2500\u2500 RESOURCE SUMMARY \u2500\u2500\nconst rsItems = audits['resource-summary']?.details?.items || [];\nconst totalItem = rsItems.find(i => i.resourceType === 'total') || {};\nconst resourceBreakdown = rsItems\n  .filter(i => i.resourceType !== 'total' && i.requestCount > 0)\n  .map(i => ({ type: i.label, requests: i.requestCount, size_kb: Math.round(i.transferSize / 1024) }));\n\n// \u2500\u2500 DIAGNOSTICS \u2500\u2500\nconst longTasksDisplay  = audits['long-tasks']?.displayValue || '0';\nconst layoutShiftsDisplay = audits['layout-shifts']?.displayValue || '0';\nconst mainThreadMs = Math.round(parseFloat(audits['mainthread-work-breakdown']?.displayValue || '0') * 1000);\nconst jsBootupMs   = safeMs(audits['bootup-time']?.numericValue);\nconst domSize      = safeMs(audits['dom-size']?.numericValue);\nconst totalWeight  = safeMs(audits['total-byte-weight']?.numericValue);\n\n// \u2500\u2500 UNSIZED IMAGES \u2500\u2500\nconst unsizedImages = (audits['unsized-images']?.details?.items || []).slice(0, 5).map(i => ({\n  url: i.url || '', selector: i.node?.selector || ''\n}));\n\n// \u2500\u2500 LCP ELEMENT \u2500\u2500\nlet lcpElement = '';\nlet lcpIssue   = '';\nconst lcpDiscovery = audits['lcp-discovery-insight'] || {};\nconst lcpItems = lcpDiscovery.details?.items || [];\nif (lcpItems.length > 0) {\n  // First item has properties, second has the element details\n  const props = lcpItems[0]?.items;\n  if (props?.priorityHinted?.value === false) lcpIssue += 'Missing fetchpriority=high. ';\n  if (lcpItems[1]?.snippet) lcpElement = lcpItems[1].snippet.substring(0, 100);\n  else if (lcpItems[1]?.selector) lcpElement = lcpItems[1].selector;\n}\n\n// LCP breakdown timing\nconst lcpBreakdown = [];\nconst lbItems = audits['lcp-breakdown-insight']?.details?.items || [];\nif (lbItems[0]?.items) {\n  lbItems[0].items.forEach(part => {\n    lcpBreakdown.push({ label: part.label, duration_ms: Math.round(part.duration) });\n  });\n}\n\n// \u2500\u2500 BUILD PSI OBJECT \u2500\u2500\nconst psi = {\n  // Meta\n  tested_url:          lr.requestedUrl || audit.meta.website,\n  fetch_time:          lr.fetchTime || '',\n  strategy:            cfgSettings.formFactor || cfgSettings.emulatedFormFactor || 'mobile',\n  lighthouse_version:  lr.lighthouseVersion || '',\n  \n  // Overall performance score\n  performance_score:   safeScore(cats.performance?.score),\n  \n  // Core Web Vitals \u2014 Lab (Lighthouse)\n  lcp: {\n    ms:           lcp_ms,\n    display:      audits['largest-contentful-paint']?.displayValue || `${(lcp_ms/1000).toFixed(1)} s`,\n    score:        safeScore(audits['largest-contentful-paint']?.score),\n    status:       cwvStatus('lcp', lcp_ms),\n    element:      lcpElement,\n    issue:        lcpIssue,\n    breakdown:    lcpBreakdown,\n  },\n  fcp: {\n    ms:      fcp_ms,\n    display: audits['first-contentful-paint']?.displayValue || `${(fcp_ms/1000).toFixed(1)} s`,\n    score:   safeScore(audits['first-contentful-paint']?.score),\n    status:  cwvStatus('fcp', fcp_ms),\n  },\n  tbt: {\n    ms:      tbt_ms,\n    display: audits['total-blocking-time']?.displayValue || `${tbt_ms} ms`,\n    score:   safeScore(audits['total-blocking-time']?.score),\n    status:  cwvStatus('tbt', tbt_ms),\n  },\n  cls: {\n    value:   cls_val,\n    display: audits['cumulative-layout-shift']?.displayValue || cls_val.toFixed(3),\n    score:   safeScore(audits['cumulative-layout-shift']?.score),\n    status:  cwvStatus('cls', cls_val),\n    shifts:  parseInt(layoutShiftsDisplay) || 0,\n  },\n  si: {\n    ms:      si_ms,\n    display: audits['speed-index']?.displayValue || `${(si_ms/1000).toFixed(1)} s`,\n    score:   safeScore(audits['speed-index']?.score),\n    status:  cwvStatus('si', si_ms),\n  },\n  tti: {\n    ms:      tti_ms,\n    display: audits['interactive']?.displayValue || `${(tti_ms/1000).toFixed(1)} s`,\n    score:   safeScore(audits['interactive']?.score),\n    status:  cwvStatus('tti', tti_ms),\n  },\n  ttfb: {\n    ms:      ttfb_ms,\n    display: `${ttfb_ms} ms`,\n    status:  cwvStatus('ttfb', ttfb_ms),\n    raw_display: audits['server-response-time']?.displayValue || `${ttfb_ms} ms`,\n  },\n\n  // Field data (CrUX \u2014 real users, may not be available for small sites)\n  field_data_available: loadExp.overall_category ? true : false,\n  field_overall:        loadExp.overall_category || null,\n  field_lcp:            fieldLCP ? { percentile: fieldLCP.percentile, category: fieldLCP.category } : null,\n  field_fid:            fieldFID ? { percentile: fieldFID.percentile, category: fieldFID.category } : null,\n  field_cls:            fieldCLS ? { percentile: fieldCLS.percentile, category: fieldCLS.category } : null,\n  field_inp:            fieldINP ? { percentile: fieldINP.percentile, category: fieldINP.category } : null,\n\n  // Opportunities\n  opportunities: {\n    unused_javascript: {\n      savings_kb:  Math.round((unusedJsAudit.numericValue || 0) / 1024),\n      savings_lcp_ms: unusedJsAudit.metricSavings?.LCP || 0,\n      display:     unusedJsAudit.displayValue || '',\n      score:       unusedJsAudit.score,\n      items:       (unusedJsAudit.details?.items || []).slice(0, 5).map(i => ({\n        url: i.url || '', savings_kb: Math.round((i.wastedBytes || 0) / 1024)\n      }))\n    },\n    unused_css: {\n      savings_kb: Math.round((unusedCssAudit.numericValue || 0) / 1024),\n      display:    unusedCssAudit.displayValue || '',\n      score:      unusedCssAudit.score,\n      items:      (unusedCssAudit.details?.items || []).slice(0, 5).map(i => ({\n        url: i.url || '', savings_kb: Math.round((i.wastedBytes || 0) / 1024)\n      }))\n    },\n    render_blocking: {\n      savings_ms: Math.round(renderBlkAudit.numericValue || 0),\n      display:    renderBlkAudit.displayValue || '',\n      score:      renderBlkAudit.score,\n      items:      (renderBlkAudit.details?.items || []).slice(0, 8).map(i => ({\n        url: i.url || '', wasted_ms: Math.round(i.wastedMs || 0),\n        size_kb: Math.round((i.totalBytes || 0) / 1024)\n      }))\n    },\n    cache_policy: {\n      savings_kb: Math.round((cacheAudit.numericValue || 0) / 1024),\n      display:    cacheAudit.displayValue || '',\n      score:      cacheAudit.score,\n    },\n    total_potential_lcp_savings_ms: Math.round(totalOpportunitySavingsMs),\n  },\n\n  // Diagnostics\n  diagnostics: {\n    long_tasks:         parseInt(longTasksDisplay) || 0,\n    layout_shifts:      parseInt(layoutShiftsDisplay) || 0,\n    main_thread_ms:     mainThreadMs,\n    js_execution_ms:    jsBootupMs,\n    total_requests:     totalItem.requestCount || 0,\n    total_size_kb:      Math.round((totalItem.transferSize || totalWeight || 0) / 1024),\n    unsized_images:     unsizedImages,\n  },\n\n  // Page weight breakdown\n  resource_breakdown: resourceBreakdown,\n};\n\n// \u2500\u2500 MERGE PSI INTO AUDIT \u2500\u2500\nconst fullAudit = {\n  ...audit,\n  psi,\n};\n\nreturn [{ json: fullAudit }];"
      },
      "typeVersion": 2
    },
    {
      "id": "0286ef80-3d05-4d88-b0ff-f70470cc83fb",
      "name": "Page Speed Insights",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2224,
        4256
      ],
      "parameters": {
        "url": "https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed",
        "options": {},
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $('Input Cleaner').item.json.URL }}"
            },
            {
              "name": "category",
              "value": "BEST_PRACTICES"
            },
            {
              "name": "strategy",
              "value": "MOBILE"
            },
            {
              "name": "key",
              "value": "Your-psi-api-key"
            },
            {
              "name": "category",
              "value": "PERFORMANCE"
            },
            {
              "name": "category",
              "value": "ACCESSIBILITY"
            },
            {
              "name": "category",
              "value": "SEO"
            },
            {
              "name": "category",
              "value": "BEST_PRACTICES"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "d627609e-4d8f-4820-af09-a44e8383f02f",
      "name": "Input Cleaner",
      "type": "n8n-nodes-base.set",
      "position": [
        -32,
        4560
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "25042583-ced8-468e-972a-b0ded3b3f7f3",
              "name": "URL",
              "type": "string",
              "value": "={{ $json.extracted_url }}"
            },
            {
              "id": "38f01e54-2d4a-4f34-b758-e359423037b3",
              "name": "watermark",
              "type": "string",
              "value": "AuditCore"
            },
            {
              "id": "b3838b53-cb52-40ee-97ad-6f1689e58d91",
              "name": "message_id",
              "type": "string",
              "value": "={{ $json.event_ts }}"
            },
            {
              "id": "a6b25987-609a-45cc-b4ef-9b85b2f717e2",
              "name": "channel_id",
              "type": "string",
              "value": "={{ $json.channel }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "28010a06-1edf-4125-a061-d23371cef8c7",
      "name": "Switch",
      "type": "n8n-nodes-base.switch",
      "position": [
        864,
        4432
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "4e6aa3b9-9339-4287-92c3-7285facd9290",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.status }}",
                    "rightValue": "done"
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "f7c4019b-5ef2-4e8f-ad59-0d21385470ba",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.status }}",
                    "rightValue": "timeout"
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "dffee38b-04e4-474b-96b0-096cbf6489e5",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.status }}",
                    "rightValue": "failed"
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "9df4f4d4-d031-4f61-9498-5fd4ac488511",
                    "operator": {
                      "name": "filter.operator.equals",
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json.status }}",
                    "rightValue": "running"
                  }
                ]
              }
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.4
    },
    {
      "id": "73b8ab9f-223a-4711-9568-a133e91dc281",
      "name": "Start Crawl",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": null,
      "position": [
        192,
        4560
      ],
      "parameters": {
        "url": "https://frog-api.salmanpro.me/v1/crawl/start",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"url\": \"{{ $json.URL }}\",\n  \"exportTabs\": [\n    \"Internal:HTML\",\n    \"Response Codes:Client Error (4xx)\",\n    \"Response Codes:Server Error (5xx)\",\n    \"Response Codes:Redirection (3xx)\",\n    \"Response Codes:Blocked by Robots.txt\",\n    \"Response Codes:Internal Redirect Chain\",\n    \"Page Titles:Missing\",\n    \"Page Titles:Duplicate\",\n    \"Page Titles:Over X Characters\",\n    \"Meta Description:Missing\",\n    \"Meta Description:Duplicate\",\n    \"Meta Description:Over X Characters\",\n    \"Meta Description:Below X Characters\",\n    \"H1:Missing\",\n    \"H1:Duplicate\",\n    \"H1:Multiple\",\n    \"H2:Missing\",\n    \"Images:Missing Alt Text\",\n    \"Images:Over X KB\",\n    \"Canonicals:Missing\",\n    \"Canonicals:Non-Indexable Canonical\",\n    \"Canonicals:Multiple Conflicting\",\n    \"Directives:Noindex\",\n    \"Content:Exact Duplicates\",\n    \"Content:Near Duplicates\",\n    \"Content:Low Content Pages\",\n    \"Security:HTTP URLs\",\n    \"Security:Mixed Content\"\n  ],\n  \"bulkExports\": [\n    \"Response Codes:Internal & External:Client Error (4xx) Inlinks\"\n  ],\n  \"reports\": [\n    \"Crawl Overview\",\n    \"Issues Overview\",\n    \"Redirects:Redirect Chains\",\n    \"Redirects:Redirect & Canonical Chains\",\n    \"Canonicals:Non-Indexable Canonicals\"\n  ],\n  \"overwrite\": true\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            }
          ]
        }
      },
      "retryOnFail": false,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "89140d15-c9aa-4058-aead-3ffeeaec1894",
      "name": "Check Crawl",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 2,
      "position": [
        640,
        4560
      ],
      "parameters": {
        "url": "=https://frog-api.salmanpro.me/v1/crawl/status/{{ $json.task_id }}",
        "options": {},
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "Bearer "
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 2000
    },
    {
      "id": "4bac123f-0f66-4bb7-b9d5-95b9e4b05763",
      "name": "Fetch",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": null,
      "position": [
        1536,
        4368
      ],
      "parameters": {
        "url": "=https://frog-api.salmanpro.me/v1/crawl/download/{{ $('Arrange Data').item.json.task_id }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "Bearer "
            }
          ]
        }
      },
      "retryOnFail": false,
      "typeVersion": 4.2,
      "waitBetweenTries": 5000
    },
    {
      "id": "4560d1f4-c544-4940-8f5a-c0ee7e05960e",
      "name": "SEO Audit Parser",
      "type": "n8n-nodes-base.code",
      "position": [
        1968,
        4256
      ],
      "parameters": {
        "jsCode": "// ================================================================\n// SEO AUDIT PARSER \u2014 Production v5.3\n// FIXES FROM REPORT OVERVIEW REVIEW:\n//\n//  FIX 1: real4xxCount \u2014 was broken_link_sources.length (capped at 25)\n//          now = getCount('response_codes_client_error_(4xx).csv')\n//          \u2192 Cover and health score now show 111, not 25\n//\n//  FIX 2: broken_link_sources \u2014 now includes ALL 4xx (internal + external)\n//          separated by issue_type field: 'Internal 404' or 'External 404'\n//          Table shows top 25 by inlinks across both categories\n//\n//  FIX 3: Thin content threshold standardised to 300 words everywhere\n//          (was 200 in issue card label, 300 in parser logic \u2014 now consistent)\n//\n//  FIX 4: Depth buckets \u2014 crawlOverview.depth used as primary source,\n//          falls back to iStats.depthBuckets if crawl_overview has 0s\n//\n//  FIX 5: Health score 4xx \u2014 uses raw SF 4xx count (all 4xx, not just 25)\n//\n//  UNCHANGED: Everything else from v5.1 \u2014 all fields preserved\n// ================================================================\n\nconst item   = $input.first();\nconst binary = item.binary;\nif (!binary) throw new Error('No binary data. Check Compression node is connected.');\n\n// \u2500\u2500 Line parser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction parseLine(line) {\n  const result = [];\n  let cur = '', inQ = false;\n  for (let i = 0; i < line.length; i++) {\n    const c = line[i];\n    if (c === '\"') {\n      if (inQ && line[i + 1] === '\"') { cur += '\"'; i++; }\n      else inQ = !inQ;\n    } else if (c === ',' && !inQ) { result.push(cur.trim()); cur = ''; }\n    else { cur += c; }\n  }\n  result.push(cur.trim());\n  return result;\n}\n\n// \u2500\u2500 Chunk-based CSV parser \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction parseCSVFromBuffer(buffer, maxRows = 99999, earlyExit = false) {\n  const CHUNK = 65536;\n  const headers = [], rows = [];\n  let totalRows = 0, hParsed = false, rem = '';\n  let start = (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) ? 3 : 0;\n\n  for (let off = start; off < buffer.length; off += CHUNK) {\n    const chunk    = buffer.slice(off, Math.min(off + CHUNK, buffer.length)).toString('utf-8');\n    const combined = rem + chunk;\n    const nli      = combined.lastIndexOf('\\n');\n    if (nli === -1) { rem = combined; continue; }\n    const lines = combined.substring(0, nli + 1).split('\\n');\n    rem = combined.substring(nli + 1);\n    for (const raw of lines) {\n      const line = raw.replace(/\\r$/, '').trim();\n      if (!line) continue;\n      if (!hParsed) { parseLine(line).forEach(h => headers.push(h.replace(/^\\uFEFF/, ''))); hParsed = true; }\n      else {\n        totalRows++;\n        if (rows.length < maxRows) {\n          const vals = parseLine(line);\n          const obj  = {};\n          headers.forEach((h, i) => { obj[h] = (vals[i] ?? '').trim(); });\n          rows.push(obj);\n        } else if (earlyExit) { return { headers, rows, totalRows: rows.length }; }\n      }\n    }\n  }\n  if (rem.trim() && hParsed) {\n    totalRows++;\n    if (rows.length < maxRows) {\n      const vals = parseLine(rem.replace(/\\r$/, '').trim());\n      const obj  = {};\n      headers.forEach((h, i) => { obj[h] = (vals[i] ?? '').trim(); });\n      rows.push(obj);\n    }\n  }\n  return { headers, rows, totalRows };\n}\n\n// \u2500\u2500 Streaming internal_html aggregator \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction processInternalHTML(buffer) {\n  const CHUNK = 65536;\n  let headers = null, ci = {}, hParsed = false;\n  let totalRows = 0, sumRT = 0, sumWC = 0;\n  let indexableCount = 0, nonIndexableCount = 0, h1 = 0, h2 = 0;\n  const speedBuckets = { excellent:0, good:0, needs_work:0, slow:0, critical:0 };\n  const wcBuckets    = { empty:0, thin:0, medium:0, good:0, rich:0 };\n  const depthBuckets = { d1:0, d2:0, d3:0, d4:0, d5plus:0 };\n  const slowestArr   = [], orphanArr = [], thinArr = [];\n\n  const sf = v => { const n = parseFloat(v || '0'); return isNaN(n) ? 0 : n; };\n  const si = v => { const n = parseInt(v   || '0'); return isNaN(n) ? 0 : n; };\n\n  function processRow(vals) {\n    if (!vals || vals.length < 2) return;\n    const rt      = sf(vals[ci.rt]);\n    const wc      = si(vals[ci.wc]);\n    const depth   = si(vals[ci.depth]);\n    const inlinks = si(ci.inlinks >= 0 ? vals[ci.inlinks] : (ci.uInlinks >= 0 ? vals[ci.uInlinks] : '0'));\n    const hv      = (ci.hv      >= 0 ? vals[ci.hv]      : '') || '';\n    const url     = (ci.url     >= 0 ? vals[ci.url]     : '') || '';\n    const idx     = (ci.idx     >= 0 ? vals[ci.idx]     : '') || '';\n    const status  = si(ci.status >= 0 ? vals[ci.status] : '0');\n    const content = (ci.content >= 0 ? vals[ci.content] : '') || '';\n    const sizeB   = si(ci.size   >= 0 ? vals[ci.size]   : '0');\n\n    totalRows++; sumRT += rt; sumWC += wc;\n    if      (idx === 'Indexable')     indexableCount++;\n    else if (idx === 'Non-Indexable') nonIndexableCount++;\n\n    if      (rt < 0.2) speedBuckets.excellent++;\n    else if (rt < 0.5) speedBuckets.good++;\n    else if (rt < 1.0) speedBuckets.needs_work++;\n    else if (rt < 2.0) speedBuckets.slow++;\n    else               speedBuckets.critical++;\n\n    if (rt >= 1.5 && slowestArr.length < 500) slowestArr.push({ url, rt: rt.toFixed(3), size_kb: Math.round(sizeB / 1024) });\n\n    const isHtml = !content || content.toLowerCase().includes('html');\n    if (isHtml) {\n      if      (wc === 0)   wcBuckets.empty++;\n      else if (wc <= 200)  wcBuckets.thin++;\n      else if (wc <= 600)  wcBuckets.medium++;\n      else if (wc <= 1500) wcBuckets.good++;\n      else                 wcBuckets.rich++;\n      // FIX: threshold = 300 words (consistent everywhere)\n      if (wc > 0 && wc < 300 && status === 200 && idx === 'Indexable' && thinArr.length < 500)\n        thinArr.push({ url, word_count: wc });\n    }\n\n    if      (depth <= 1)  depthBuckets.d1++;\n    else if (depth === 2) depthBuckets.d2++;\n    else if (depth === 3) depthBuckets.d3++;\n    else if (depth === 4) depthBuckets.d4++;\n    else                  depthBuckets.d5plus++;\n\n    if (inlinks === 0 && idx === 'Indexable' && status === 200 && url && isHtml && orphanArr.length < 500)\n      orphanArr.push({ url, word_count: wc, depth });\n\n    if (hv.includes('2')) h2++; else h1++;\n  }\n\n  let rem = '', start = (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) ? 3 : 0;\n\n  for (let off = start; off < buffer.length; off += CHUNK) {\n    const chunk    = buffer.slice(off, Math.min(off + CHUNK, buffer.length)).toString('utf-8');\n    const combined = rem + chunk;\n    const nli      = combined.lastIndexOf('\\n');\n    if (nli === -1) { rem = combined; continue; }\n    const lines = combined.substring(0, nli + 1).split('\\n');\n    rem = combined.substring(nli + 1);\n    for (const raw of lines) {\n      const line = raw.replace(/\\r$/, '').trim();\n      if (!line) continue;\n      if (!hParsed) {\n        headers = parseLine(line);\n        const fi = (...names) => { for (const n of names) { const i = headers.indexOf(n); if (i !== -1) return i; } return -1; };\n        ci = {\n          url:      fi('Address'),\n          rt:       fi('Response Time'),\n          wc:       fi('Word Count'),\n          depth:    fi('Crawl Depth'),\n          inlinks:  fi('Inlinks'),\n          uInlinks: fi('Unique Inlinks'),\n          hv:       fi('HTTP Version'),\n          idx:      fi('Indexability'),\n          status:   fi('Status Code'),\n          content:  fi('Content'),\n          size:     fi('Size (bytes)'),\n        };\n        hParsed = true;\n      } else { processRow(parseLine(line)); }\n    }\n  }\n  if (rem.replace(/\\r$/, '').trim() && hParsed) processRow(parseLine(rem.replace(/\\r$/, '').trim()));\n\n  slowestArr.sort((a, b) => parseFloat(b.rt) - parseFloat(a.rt));\n  thinArr.sort((a, b) => a.word_count - b.word_count);\n\n  return {\n    totalRows, sumRT, sumWC,\n    indexableCount, nonIndexableCount,\n    speedBuckets, wcBuckets, depthBuckets,\n    slowestPages: slowestArr.slice(0, 10),\n    orphanPages:  orphanArr.slice(0, 25),\n    thinPages:    thinArr.slice(0, 15),\n    http1Count: h1, http2Count: h2,\n  };\n}\n\n// \u2500\u2500 Process internal_html.csv \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ihKey = Object.keys(binary).find(k => binary[k]?.fileName === 'internal_html.csv');\nlet iStats = {\n  totalRows:0, sumRT:0, sumWC:0, indexableCount:0, nonIndexableCount:0,\n  speedBuckets:{excellent:0,good:0,needs_work:0,slow:0,critical:0},\n  wcBuckets:{empty:0,thin:0,medium:0,good:0,rich:0},\n  depthBuckets:{d1:0,d2:0,d3:0,d4:0,d5plus:0},\n  slowestPages:[], orphanPages:[], thinPages:[], http1Count:0, http2Count:0,\n};\nif (ihKey) {\n  const buf = await this.helpers.getBinaryDataBuffer(0, ihKey);\n  iStats = processInternalHTML(buf);\n}\n\nconst totalInternal     = iStats.totalRows || 1;\nconst indexableCount    = iStats.indexableCount;\nconst nonIndexableCount = iStats.nonIndexableCount;\nconst speedBuckets      = iStats.speedBuckets;\nconst wcBuckets         = iStats.wcBuckets;\nconst depthBuckets      = iStats.depthBuckets;\nconst slowestPages      = iStats.slowestPages;\nconst thinContentPages  = iStats.thinPages;\nconst http1Count        = iStats.http1Count;\nconst http2Count        = iStats.http2Count;\nconst avgResponseTime   = totalInternal > 0 ? (iStats.sumRT / totalInternal).toFixed(3) : '0.000';\nconst avgWordCount      = totalInternal > 0 ? Math.round(iStats.sumWC / totalInternal) : 0;\n\n// \u2500\u2500 File loading config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst SKIP_FILES = new Set(['all_inlinks.csv', 'all_anchor_text.csv']);\nconst CORE_FILES = new Set([\n  'internal_html.csv', 'issues_overview_report.csv', 'crawl_overview.csv',\n  'client_error_(4xx)_inlinks.csv', 'response_codes_redirection_(3xx).csv',\n  'redirect_chains.csv', 'hreflang_all_issues.csv', 'page_titles_missing.csv',\n  'page_titles_duplicate.csv', 'page_titles_over_60_characters.csv',\n  'meta_description_missing.csv', 'meta_description_over_155_characters.csv',\n  'h1_missing.csv', 'h1_duplicate.csv', 'h1_multiple.csv', 'h2_missing.csv',\n  'images_missing_alt_text.csv', 'images_over_100_kb.csv', 'directives_noindex.csv',\n  'canonicals_missing.csv', 'canonicals_multiple_conflicting.csv',\n  'canonicals_nonindexable_canonical.csv', 'content_exact_duplicates.csv',\n  'content_near_duplicates.csv', 'content_low_content_pages.csv',\n  'security_http_urls.csv', 'security_mixed_content.csv',\n  'response_codes_client_error_(4xx).csv', 'response_codes_server_error_(5xx).csv',\n  'response_codes_blocked_by_robots_txt.csv', 'redirect_and_canonical_chains.csv',\n  'page_titles_below_30_characters.csv', 'meta_description_duplicate.csv',\n  'meta_description_below_70_characters.csv',\n]);\nconst SIZE_SKIP_THRESHOLD = 30 * 1024 * 1024;\nconst SAMPLE_LIMITS = {\n  'client_error_(4xx)_inlinks.csv':          25000,\n  'response_codes_redirection_(3xx).csv':    25000,\n};\n\nconst files = {};\nfor (const key of Object.keys(binary)) {\n  const file = binary[key];\n  if (!file?.fileName || file.fileName === 'internal_html.csv') continue;\n  if (SKIP_FILES.has(file.fileName)) { files[file.fileName] = { headers:[], rows:[], totalRows:0, skipped:true }; continue; }\n  const fileBytes = file.fileSize || 0;\n  if (fileBytes > SIZE_SKIP_THRESHOLD && !CORE_FILES.has(file.fileName)) {\n    files[file.fileName] = { headers:[], rows:[], totalRows:0, skipped:true, reason:`auto_skipped_${Math.round(fileBytes/1024/1024)}MB` };\n    continue;\n  }\n  let buf;\n  try { buf = await this.helpers.getBinaryDataBuffer(0, key); }\n  catch (e) { files[file.fileName] = { headers:[], rows:[], totalRows:0, error:e.message }; continue; }\n  files[file.fileName] = parseCSVFromBuffer(buf, SAMPLE_LIMITS[file.fileName] ?? 99999, false);\n}\n\n// \u2500\u2500 Accessors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst getFile   = n => files[n] || { headers:[], rows:[], totalRows:0 };\nconst getRows   = n => getFile(n).rows || [];\nconst getCount  = n => { if (n === 'internal_html.csv') return iStats.totalRows; return getFile(n).totalRows || 0; };\nconst getSample = (n, limit = 10) => getRows(n).slice(0, limit);\nconst safeInt   = v => { const n = parseInt(v || '0'); return isNaN(n) ? 0 : n; };\nconst safeFloat = v => { const n = parseFloat(v || '0'); return isNaN(n) ? 0 : n; };\n\n// \u2500\u2500 Crawl overview parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ovFile = getFile('crawl_overview.csv');\nconst ovMeta = {};\nlet websiteUrl = '';\n\nif (ovFile.headers?.length >= 2) {\n  const col0 = ovFile.headers[0], col1 = ovFile.headers[1];\n  if (col1?.startsWith('http')) websiteUrl = col1;\n  let currentSection = '';\n  ovFile.rows.forEach(r => {\n    const k = (r[col0] || '').trim(), v = (r[col1] || '').trim();\n    if (k && v === '') { currentSection = k; }\n    else if (k && v !== '') {\n      ovMeta[k] = v;\n      if (currentSection) ovMeta[`${currentSection}::${k}`] = v;\n    }\n    if (k === 'Site Crawled' && v.startsWith('http')) websiteUrl = v;\n  });\n}\n\nfunction getDomain(url) {\n  try   { return new URL(url).hostname.toLowerCase().replace(/^www\\./, ''); }\n  catch { return (url || '').replace(/https?:\\/\\//, '').split('/')[0].toLowerCase().replace(/^www\\./, ''); }\n}\nconst siteDomain = websiteUrl ? getDomain(websiteUrl) : '';\n\nconst ov = (section, key) => {\n  if (section && ovMeta[`${section}::${key}`] !== undefined) return safeInt(ovMeta[`${section}::${key}`]);\n  return safeInt(ovMeta[key] || '0');\n};\n\n// Build structured crawl overview object\nconst crawlOverview = {\n  site_crawled:           websiteUrl,\n  crawl_date:             ovMeta['Date'] || '',\n  total_urls_encountered: ov('Summary','Total URLs Encountered') || ov('','Total URLs Encountered'),\n  total_urls_crawled:     ov('Summary','Total URLs Crawled')     || ov('','Total URLs Crawled'),\n  internal: {\n    all:           ov('Internal','All')        || ov('Summary','Total Internal URLs'),\n    html:          ov('Internal','HTML'),\n    javascript:    ov('Internal','JavaScript'),\n    css:           ov('Internal','CSS'),\n    images:        ov('Internal','Images'),\n    pdf:           ov('Internal','PDF'),\n    other:         ov('Internal','Other'),\n    indexable:     ov('Summary','Total Internal Indexable URLs'),\n    non_indexable: ov('Summary','Total Internal Non-Indexable URLs'),\n  },\n  external: {\n    all:  ov('External','All') || ov('Summary','Total External URLs'),\n    html: ov('External','HTML'),\n  },\n  // FIX 3: Use crawlOverview response codes for the 4-card breakdown\n  response_codes: {\n    internal_2xx: ov('Response Codes','Internal Success (2xx)'),\n    internal_3xx: ov('Response Codes','Internal Redirection (3xx)'),\n    internal_4xx: ov('Response Codes','Internal Client Error (4xx)'),\n    internal_5xx: ov('Response Codes','Internal Server Error (5xx)'),\n    external_2xx: ov('Response Codes','External Success (2xx)'),\n    external_3xx: ov('Response Codes','External Redirection (3xx)'),\n    external_4xx: ov('Response Codes','External Client Error (4xx)'),\n    no_response:  ov('Response Codes','No Response') || ov('Response Codes','Internal No Response'),\n    success_2xx:  ov('Response Codes','Success (2xx)'),\n    redirect_3xx: ov('Response Codes','Redirection (3xx)'),\n    error_4xx:    ov('Response Codes','Client Error (4xx)'),\n    error_5xx:    ov('Response Codes','Server Error (5xx)'),\n  },\n  security: {\n    http_urls:     ov('Security','HTTP URLs'),\n    https_urls:    ov('Security','HTTPS URLs'),\n    mixed_content: ov('Security','Mixed Content'),\n  },\n  page_titles: {\n    all:      ov('Page Titles','All'),\n    missing:  ov('Page Titles','Missing'),\n    duplicate:ov('Page Titles','Duplicate'),\n    over_60:  ov('Page Titles','Over 60 Characters'),\n    below_30: ov('Page Titles','Below 30 Characters'),\n  },\n  meta_description: {\n    missing:  ov('Meta Description','Missing'),\n    duplicate:ov('Meta Description','Duplicate'),\n    over_155: ov('Meta Description','Over 155 Characters'),\n    below_70: ov('Meta Description','Below 70 Characters'),\n  },\n  h1: { missing:ov('H1','Missing'), duplicate:ov('H1','Duplicate'), multiple:ov('H1','Multiple') },\n  h2: { missing:ov('H2','Missing'), multiple:ov('H2','Multiple') },\n  images_overview: {\n    all:         ov('Images','All'),\n    over_100kb:  ov('Images','Over 100 KB'),\n    missing_alt: ov('Images','Missing Alt Text'),\n  },\n  canonicals: {\n    all:                 ov('Canonicals','All'),\n    missing:             ov('Canonicals','Missing'),\n    multiple_conflicting:ov('Canonicals','Multiple Conflicting'),\n    non_indexable:       ov('Canonicals','Non-Indexable Canonical'),\n  },\n  content: {\n    low_content:ov('Content','Low Content Pages'),\n    exact_dup:  ov('Content','Exact Duplicates'),\n    near_dup:   ov('Content','Near Duplicates'),\n  },\n  // FIX 4: Depth from crawl overview (primary source)\n  depth: {\n    d0:    ov('Depth (Clicks from Start URL)','0'),\n    d1:    ov('Depth (Clicks from Start URL)','1'),\n    d2:    ov('Depth (Clicks from Start URL)','2'),\n    d3:    ov('Depth (Clicks from Start URL)','3'),\n    d4:    ov('Depth (Clicks from Start URL)','4'),\n    d5:    ov('Depth (Clicks from Start URL)','5'),\n    d6:    ov('Depth (Clicks from Start URL)','6'),\n    d7:    ov('Depth (Clicks from Start URL)','7'),\n    d8:    ov('Depth (Clicks from Start URL)','8'),\n    d9:    ov('Depth (Clicks from Start URL)','9'),\n    d10plus:ov('Depth (Clicks from Start URL)','10+'),\n  },\n};\n\n// \u2500\u2500 Issues overview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst SECURITY_HEADER_NOISE = new Set([\n  'Security: Missing Secure Referrer-Policy Header',\n  'Security: Missing X-Frame-Options Header',\n  'Security: Missing HSTS Header',\n  'Security: Missing X-Content-Type-Options Header',\n  'Security: Missing Content-Security-Policy Header',\n]);\nconst allIssuesRaw = getRows('issues_overview_report.csv').map(r => ({\n  name:     r['Issue Name']     || '',\n  type:     r['Issue Type']     || '',\n  priority: r['Issue Priority'] || '',\n  count:    safeInt(r['URLs']),\n  percent:  r['% of Total']    || '0',\n  is_noise: SECURITY_HEADER_NOISE.has(r['Issue Name'] || '')\n})).filter(i => i.count > 0);\n\nconst issuesOverview          = allIssuesRaw.filter(i => !i.is_noise);\nconst securityHeaderNoise     = allIssuesRaw.filter(i => i.is_noise);\nconst securityHeaderNoiseCount = securityHeaderNoise.reduce((s, i) => s + i.count, 0);\n\n// \u2500\u2500 Broken links \u2014 FIX 1 + FIX 2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// FIX 2: Build ALL 4xx broken links (internal AND external)\n// They are all the client's responsibility per user instruction\nconst brokenMapAll = {};\ngetRows('client_error_(4xx)_inlinks.csv').forEach(r => {\n  const dest   = r['Destination'] || '';\n  const src    = r['Source']      || '';\n  const anchor = r['Anchor']      || '';\n  const code   = r['Status Code'] || '';\n  if (!dest || dest.includes('cdn-cgi')) return;\n  if (!brokenMapAll[dest]) {\n    const destDomain = getDomain(dest);\n    const isInternal = siteDomain && (destDomain === siteDomain || destDomain.endsWith('.' + siteDomain));\n    brokenMapAll[dest] = {\n      broken_url: dest, status_code: code, anchor_text: anchor,\n      source_pages: [], total_inlinks: 0,\n      issue_type: isInternal ? 'Internal 404' : 'External 404'\n    };\n  }\n  brokenMapAll[dest].total_inlinks++;\n  if (src && !brokenMapAll[dest].source_pages.includes(src)) brokenMapAll[dest].source_pages.push(src);\n});\n\nconst REAL_BROKEN = new Set(['404', '410', '400']);\nconst BOT_CODES   = new Set(['403', '429', '401', '999']);\n\n// All 404-type broken sorted by inlinks \u2014 both internal and external\nconst allBroken404 = Object.values(brokenMapAll)\n  .filter(b => REAL_BROKEN.has(b.status_code))\n  .sort((a, b) => b.total_inlinks - a.total_inlinks);\n\n// broken_link_sources = ALL real 404s (internal + external), top 25 by inlinks\n// issue_type field distinguishes them: 'Internal 404' or 'External 404'\nconst broken_link_sources = allBroken404.slice(0, 25).map(b => ({\n  broken_url:     b.broken_url,\n  status_code:    b.status_code,\n  anchor_text:    b.anchor_text,\n  total_inlinks:  b.total_inlinks,\n  pages_affected: b.source_pages.length,\n  source_pages:   b.source_pages.slice(0, 5),\n  issue_type:     b.issue_type,   // 'Internal 404' or 'External 404'\n}));\n\n// COUNT HIERARCHY (each serves a different purpose):\n//\n// 1. broken404Count  = unique URLs returning 404/410/400 from client_error_(4xx)_inlinks.csv\n//                    = shown in \"Genuinely Broken Pages\" heading + priority action + cover\n//                    = internal_404 + external_404 unique URLs\n//\n// 2. internalOnly4xxCount = Internal 4xx from crawl_overview (for HEALTH SCORE only)\n//                         = 13 for spacem12 \u2014 pages YOUR server returns 4xx for\n//\n// 3. real4xxCount    = Total ALL 4xx from crawl_overview (for the 4xx response card)\n//                    = 213 for spacem12 = internal(13) + external(200)\n//                    = includes 403, 404, 410, 429 etc.\n\nconst broken404Count       = allBroken404.length;  // unique 404/410/400 URLs \u2014 used in headings + priority\nconst internalOnly4xxCount = ov('Response Codes','Internal Client Error (4xx)') || allBroken404.filter(b => b.issue_type === 'Internal 404').length;\nconst real4xxCount         = ov('Response Codes','Client Error (4xx)') || getCount('response_codes_client_error_(4xx).csv'); // ALL 4xx for card display\n\n// 403 links \u2014 THREE categories:\n// 1. internal_403_links   = your OWN site pages blocking crawler (most serious \u2014 WAF/Cloudflare)\n// 2. bot_blocked_links    = famous external domains that block all crawlers (not actionable)\n// 3. manual_check_links   = unknown external domains \u2014 need manual check if really broken\n\n// Expanded famous domains list \u2014 sites that actively block crawlers\nconst FAMOUS_DOMAINS = [\n  // Social\n  'twitter.com','x.com','t.co','facebook.com','instagram.com','tiktok.com',\n  'linkedin.com','pinterest.com','youtube.com','reddit.com','threads.net','snapchat.com',\n  // News & Media\n  'dawn.com','bbc.com','bbc.co.uk','cnn.com','reuters.com','bloomberg.com','forbes.com',\n  'nytimes.com','theguardian.com','wsj.com','ft.com','businessinsider.com','cnbc.com',\n  'independent.co.uk','dailymail.co.uk','express.co.uk','telegraph.co.uk',\n  // Tech & Reference\n  'wikipedia.org','github.com','amazon.com','amazon.co.uk','apple.com','google.com',\n  'microsoft.com','cloudflare.com','archive.org','quora.com','medium.com','substack.com',\n  // Stock / Image sites\n  'gettyimages.com','shutterstock.com','alamy.com','istock.com','123rf.com',\n  // Streaming\n  'hotstar.com','netflix.com','hulu.com','disneyplus.com','spotify.com',\n  // E-commerce\n  'ebay.com','alibaba.com','aliexpress.com','etsy.com','walmart.com',\n  // Gov / Edu\n  'who.int','un.org','unesco.org','cswe.org','open.ac.uk','ox.ac.uk','harvard.edu',\n  // Pakistan specific\n  'geo.tv','ary.digital','samaa.tv','dunyanews.tv','express.pk','jang.com.pk',\n  'thenews.com.pk','nation.com.pk','pakobserver.net','propakistani.pk',\n  // Other commonly blocked\n  'nasaspaceflight.com','donaldjtrump.com','truthsocial.com','glassdoor.com',\n  'trustpilot.com','yelp.com','tripadvisor.com','booking.com','airbnb.com',\n];\nconst isFamousDomain = d => FAMOUS_DOMAINS.some(fd => d === fd || d.endsWith('.' + fd));\n\nconst allBroken403 = Object.values(brokenMapAll).filter(b => BOT_CODES.has(b.status_code));\n\nconst internal_403_links = allBroken403\n  .filter(b => siteDomain && getDomain(b.broken_url).includes(siteDomain)).slice(0, 20)\n  .map(b => ({ broken_url:b.broken_url, status_code:b.status_code, total_inlinks:b.total_inlinks, source_pages:b.source_pages.slice(0,3) }));\n\nconst external403 = allBroken403.filter(b => !(siteDomain && getDomain(b.broken_url).includes(siteDomain)));\n\n// Famous bot-blocked (safe to ignore \u2014 these sites block all crawlers by design)\nconst bot_blocked_links = external403\n  .filter(b => isFamousDomain(getDomain(b.broken_url))).slice(0, 20)\n  .map(b => ({ broken_url:b.broken_url, status_code:b.status_code, total_inlinks:b.total_inlinks, source_pages:b.source_pages.slice(0,3) }));\n\n// Unknown external 403s \u2014 need manual check (could be real broken links or bot-blocking)\nconst manual_check_links = external403\n  .filter(b => !isFamousDomain(getDomain(b.broken_url))).slice(0, 20)\n  .map(b => ({ broken_url:b.broken_url, status_code:b.status_code, total_inlinks:b.total_inlinks, source_pages:b.source_pages.slice(0,3) }));\n\n// \u2500\u2500 Redirects \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst redirectGroupMap  = {};\nconst SOCIAL_NOISE      = new Set(['facebook.com','twitter.com','x.com','instagram.com','linkedin.com','pinterest.com','youtube.com','tiktok.com','in.linkedin.com','uk.linkedin.com']);\nconst AFFILIATE_DOMAINS = new Set(['amzn.to','bit.ly','tinyurl.com','ow.ly','buff.ly','goo.gl','ift.tt','dlvr.it','tiny.cc','rb.gy','cutt.ly']);\n\nfunction isTrailingSlashOnly(from, to) { return from.replace(/\\/$/, '') === to.replace(/\\/$/, ''); }\n\ngetRows('response_codes_redirection_(3xx).csv').forEach(r => {\n  const from = r['Address'] || '', to = r['Redirect URL'] || r['Redirect URI'] || '', code = r['Status Code'] || '';\n  const inlinks = safeInt(r['Inlinks']);\n  if (!from || !to) return;\n  const fromD = getDomain(from), toD = getDomain(to);\n  if (AFFILIATE_DOMAINS.has(fromD)) return;\n  if (SOCIAL_NOISE.has(fromD) && isTrailingSlashOnly(from, to)) return;\n\n  const fromIsHttp = from.startsWith('http://'), toIsHttps = to.startsWith('https://');\n  const fromNoWww  = !from.replace(/https?:\\/\\//, '').startsWith('www.');\n  const toHasWww   = to.replace(/https?:\\/\\//, '').startsWith('www.');\n  const sameDomain = fromD === toD || ('www.' + fromD) === toD || fromD === toD.replace(/^www\\./, '');\n  const isClientFrom = siteDomain && (fromD.includes(siteDomain) || siteDomain.includes(fromD));\n  const isClientTo   = siteDomain && (toD.includes(siteDomain) || siteDomain.includes(toD));\n\n  let groupKey, groupLabel, groupType;\n  if (isClientFrom && isClientTo) {\n    if (fromIsHttp && toIsHttps && fromNoWww && toHasWww && sameDomain) { groupKey='__http_nonwww__'; groupLabel='HTTP + non-www \u2192 HTTPS + www (infrastructure)'; groupType='infrastructure'; }\n    else if (fromIsHttp && toIsHttps && sameDomain) { groupKey='__http_https__'; groupLabel='HTTP \u2192 HTTPS (infrastructure)'; groupType='infrastructure'; }\n    else if (fromNoWww && toHasWww && sameDomain && !fromIsHttp) { groupKey='__nonwww_www__'; groupLabel='Non-www \u2192 www (infrastructure)'; groupType='infrastructure'; }\n    else { groupKey=from; groupLabel=null; groupType='internal'; }\n  } else if (!isClientFrom && !isClientTo) {\n    if (SOCIAL_NOISE.has(fromD)) return;\n    groupKey=from; groupLabel=null; groupType='external';\n  } else { groupKey=from; groupLabel=null; groupType='external'; }\n\n  if (!redirectGroupMap[groupKey]) redirectGroupMap[groupKey] = { type:groupType, label:groupLabel||from, redirect_to:to, code, count:0, total_inlinks:0, examples:[] };\n  redirectGroupMap[groupKey].count++;\n  redirectGroupMap[groupKey].total_inlinks += inlinks;\n  if (redirectGroupMap[groupKey].examples.length < 3) redirectGroupMap[groupKey].examples.push({ from, to, inlinks, code });\n});\n\nconst sortOrder     = { infrastructure:0, internal:1, external:2 };\nconst allRedirectGroups = Object.values(redirectGroupMap).sort((a, b) => (sortOrder[a.type]??9)-(sortOrder[b.type]??9) || b.total_inlinks-a.total_inlinks);\nconst redirectGrouped         = allRedirectGroups.filter(r => r.type !== 'external').slice(0, 30);\nconst externalRedirectGrouped = allRedirectGroups.filter(r => r.type === 'external').slice(0, 15);\nconst internalRedirectCount   = redirectGrouped.reduce((sum, g) => sum + g.count, 0);\n\n// \u2500\u2500 H1 / title duplicates \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst h1TextMap = {};\ngetRows('h1_duplicate.csv').forEach(r => { const h=r['H1-1']||'', u=r['Address']||''; if(!h)return; if(!h1TextMap[h])h1TextMap[h]={h1:h,urls:[]}; h1TextMap[h].urls.push(u); });\nconst h1DuplicateGrouped = Object.values(h1TextMap).sort((a,b)=>b.urls.length-a.urls.length).slice(0,10).map(g=>({h1:g.h1,page_count:g.urls.length,sample_urls:g.urls.slice(0,3)}));\n\nconst secondH1Map = {};\ngetRows('h1_multiple.csv').forEach(r => { const h=r['H1-2']||''; if(h)secondH1Map[h]=(secondH1Map[h]||0)+1; });\nconst templateH1 = Object.entries(secondH1Map).sort((a,b)=>b[1]-a[1]).slice(0,3).map(([text,count])=>({text,count}));\n\nconst titleTextMap = {};\ngetRows('page_titles_duplicate.csv').forEach(r => { const t=r['Title 1']||'', u=r['Address']||''; if(!t)return; if(!titleTextMap[t])titleTextMap[t]={title:t,urls:[]}; titleTextMap[t].urls.push(u); });\nconst titleDuplicateGrouped = Object.values(titleTextMap).sort((a,b)=>b.urls.length-a.urls.length).slice(0,10).map(g=>({title:g.title,page_count:g.urls.length,sample_urls:g.urls.slice(0,3)}));\n\n// \u2500\u2500 Remaining sections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst missingAltRows = getSample('images_missing_alt_text.csv',15).map(r=>({url:r['Address']||'',size_kb:Math.round(safeInt(r['Size (bytes)'])/1024),inlinks:safeInt(r['IMG Inlinks'])}));\nconst oversizedRows  = getSample('images_over_100_kb.csv',15).map(r=>({url:r['Address']||'',size_kb:Math.round(safeInt(r['Size (bytes)'])/1024),inlinks:safeInt(r['IMG Inlinks'])})).sort((a,b)=>b.size_kb-a.size_kb);\n\nconst INTENTIONAL_PATTERNS = ['/tag/','/author/','/page/','/feed/','/wp-','sitemap','login','admin','cart','checkout','wp-json','xmlrpc'];\nconst allNoindex        = getRows('directives_noindex.csv').map(r=>({url:r['Address']||'',directive:r['Meta Robots 1']||''}));\nconst suspiciousNoindex = allNoindex.filter(r=>!INTENTIONAL_PATTERNS.some(p=>r.url.toLowerCase().includes(p)));\nconst noindexCount      = suspiciousNoindex.length;\n\nconst canonicalMissingRows    = getSample('canonicals_missing.csv',15).map(r=>({url:r['Address']||'',indexability:r['Indexability']||''}));\nconst canonicalMissingIndexable = canonicalMissingRows.filter(r=>r.indexability==='Indexable');\nconst metaTooLongRows         = getSample('meta_description_over_155_characters.csv',15).map(r=>({url:r['Address']||'',meta:r['Meta Description 1']||'',length:safeInt(r['Meta Description 1 Length'])})).sort((a,b)=>b.length-a.length);\nconst metaMissingRows         = getSample('meta_description_missing.csv',15).map(r=>({url:r['Address']||''}));\nconst errors4xxRows           = getSample('response_codes_client_error_(4xx).csv',15).map(r=>({url:r['Address']||'',code:r['Status Code']||'',status:r['Status']||'',inlinks:safeInt(r['Inlinks'])})).sort((a,b)=>b.inlinks-a.inlinks);\nconst errors5xxRows           = getSample('response_codes_server_error_(5xx).csv',10).map(r=>({url:r['Address']||'',code:r['Status Code']||'',status:r['Status']||'',inlinks:safeInt(r['Inlinks'])}));\nconst robotsBlockedRows       = getSample('response_codes_blocked_by_robots_txt.csv',25).map(r=>({url:r['Address']||'',robots_line:r['Matched Robots.txt Line']||''}));\nconst robotsRuleMap           = {};\nrobotsBlockedRows.forEach(r=>{const rule=r.robots_line||'Unknown rule'; robotsRuleMap[rule]=(robotsRuleMap[rule]||0)+1;});\nconst robotsRuleSummary = Object.entries(robotsRuleMap).sort((a,b)=>b[1]-a[1]).map(([rule,count])=>({rule,count}));\nconst httpPageRows      = getSample('security_http_urls.csv',15).map(r=>({url:r['Address']||'',status_code:r['Status Code']||'',status:r['Status']||''}));\nconst lowContentRows    = getSample('content_low_content_pages.csv',15).map(r=>({url:r['Address']||'',word_count:safeInt(r['Word Count'])})).sort((a,b)=>a.word_count-b.word_count);\nconst chainSample       = getRows('redirect_chains.csv').slice(0,10).map(r=>({source:r['Source']||r['Address']||Object.values(r)[0]||'',hops:safeInt(r['Number of Redirects']||r['Hops']||'2'),final:r['Final Address']||r['Final URL']||'',loop:r['Loop']||'False'}));\n\n// External redirect TRUE count from crawl_overview (not from grouped table length)\n// TRUE redirect counts from crawl_overview.csv \u2014 authoritative, not capped by slice/group limits\nconst externalRedirectTrueCount = ov('Response Codes','External Redirection (3xx)') || externalRedirectGrouped.length;\nconst internalRedirectTrueCount = ov('Response Codes','Internal Redirection (3xx)') || internalRedirectCount;\n\nconst ORPHAN_IGNORE    = ['/feed/','/wp-','/xmlrpc','/sitemap','admin','login','cart'];\nconst filteredOrphans  = iStats.orphanPages.filter(p=>!ORPHAN_IGNORE.some(ig=>p.url.toLowerCase().includes(ig))).slice(0,25);\n\n// FIX 4: Depth buckets \u2014 use crawlOverview as primary, fall back to iStats\nconst coDepth = crawlOverview.depth;\nconst depthBucketsForReport = (coDepth.d1 + coDepth.d2 + coDepth.d3 + coDepth.d4 + coDepth.d5 > 0)\n  ? {\n      d1:    coDepth.d0 + coDepth.d1,   // depth 0 (homepage) + depth 1\n      d2:    coDepth.d2,\n      d3:    coDepth.d3,\n      d4:    coDepth.d4,\n      d5plus:coDepth.d5 + coDepth.d6 + coDepth.d7 + coDepth.d8 + coDepth.d9 + coDepth.d10plus,\n    }\n  : depthBuckets; // fall back to iStats if crawl_overview depth is all zeros\n\n// \u2500\u2500 Health score \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// CRITICAL: 4xx for health score = INTERNAL ONLY\n// External 4xx = links to other broken sites \u2014 client can update links but not fix those servers\n// Internal 4xx = your own site returning errors \u2014 directly your responsibility\n// Score denominator = total internal HTML pages crawled (from internal_html.csv)\nconst healthDenominator = totalInternal || 1;\n\nconst criticalCount =\n  internalOnly4xxCount +                                        // INTERNAL 4xx only (13 for spacem12, not 213)\n  getCount('response_codes_server_error_(5xx).csv') +\n  getCount('h1_missing.csv') +\n  getCount('page_titles_missing.csv') +\n  getCount('security_http_urls.csv') +\n  getCount('content_exact_duplicates.csv');\n\nconst warningCount =\n  getCount('meta_description_missing.csv') +\n  getCount('page_titles_over_60_characters.csv') +\n  getCount('h1_multiple.csv') +\n  getCount('images_missing_alt_text.csv') +\n  getCount('content_low_content_pages.csv') +\n  noindexCount +\n  internalRedirectCount +\n  getCount('images_over_100_kb.csv');\n\nconst criticalDeduction = Math.min(50, Math.round((criticalCount / healthDenominator) * 60));\nconst warningDeduction  = Math.min(30, Math.round((warningCount  / healthDenominator) * 25));\nconst healthScore       = Math.max(0, Math.min(100, 100 - criticalDeduction - warningDeduction));\nconst healthLabel       = healthScore >= 80 ? 'Good' : healthScore >= 60 ? 'Needs Work' : healthScore >= 40 ? 'Poor' : 'Critical';\n\n// \u2500\u2500 Final audit object \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst audit = {\n  meta: {\n    website:            websiteUrl,\n    crawl_date:         ovMeta['Date'] || new Date().toISOString().split('T')[0],\n    total_urls_crawled: totalInternal,\n    indexable:          indexableCount,\n    non_indexable:      nonIndexableCount,\n    avg_response_time:  avgResponseTime,\n    avg_word_count:     avgWordCount,\n    speed_buckets:      speedBuckets,\n    slowest_pages:      slowestPages,\n    word_count_buckets: wcBuckets,\n    depth_buckets:      depthBucketsForReport,  // FIX 4: crawl_overview primary source\n    http_version:       { http1:http1Count, http2:http2Count },\n    crawl_overview:     crawlOverview,\n  },\n\n  health: { score:healthScore, label:healthLabel, critical_issues:criticalCount, warnings:warningCount, total_urls:totalInternal },\n\n  issues_overview:       issuesOverview,\n  security_header_noise: { count:securityHeaderNoiseCount, issues:securityHeaderNoise },\n\n  page_titles: {\n    missing:   { count:getCount('page_titles_missing.csv'),             rows:getSample('page_titles_missing.csv',10).map(r=>({url:r['Address']||''})) },\n    duplicate: { count:getCount('page_titles_duplicate.csv'),           grouped:titleDuplicateGrouped },\n    too_long:  { count:getCount('page_titles_over_60_characters.csv'),  rows:getSample('page_titles_over_60_characters.csv',10).map(r=>({url:r['Address']||'',title:r['Title 1']||'',length:safeInt(r['Title 1 Length'])})).sort((a,b)=>b.length-a.length) },\n    below_30:  { count:getCount('page_titles_below_30_characters.csv')||0 },\n  },\n  meta_descriptions: {\n    missing:   { count:getCount('meta_description_missing.csv'),             rows:metaMissingRows },\n    duplicate: { count:getCount('meta_description_duplicate.csv'),           rows:[] },\n    too_long:  { count:getCount('meta_description_over_155_characters.csv'), rows:metaTooLongRows },\n    too_short: { count:getCount('meta_description_below_70_characters.csv'), rows:[] },\n  },\n  headings: {\n    h1_missing:   { count:getCount('h1_missing.csv'),   rows:getSample('h1_missing.csv',10).map(r=>({url:r['Address']||''})) },\n    h1_duplicate: { count:getCount('h1_duplicate.csv'), grouped:h1DuplicateGrouped },\n    h1_multiple:  { count:getCount('h1_multiple.csv'),  template_h1:templateH1, rows:getSample('h1_multiple.csv',10).map(r=>({url:r['Address']||'',h1_first:r['H1-1']||'',h1_second:r['H1-2']||'',total:safeInt(r['Occurrences'])})) },\n    h2_missing:   { count:getCount('h2_missing.csv'),   rows:getSample('h2_missing.csv',10).map(r=>({url:r['Address']||''})) },\n  },\n  response_codes: {\n    // Counts sourced from crawl_overview.csv \u2014 exact SF numbers\n    errors_4xx: {\n      count:       real4xxCount,           // ALL 4xx (internal+external) for the 4xx response card\n      internal:    internalOnly4xxCount,   // Internal 4xx only \u2014 for health score\n      external:    ov('Response Codes','External Client Error (4xx)'),\n      broken_404:  broken404Count,         // Unique 404/410/400 URLs \u2014 for headings + priority action\n      real_count:  broken404Count,         // Alias for Report Builder compat\n      rows:        errors4xxRows\n    },\n    errors_5xx:   { count:getCount('response_codes_server_error_(5xx).csv'), rows:errors5xxRows },\n    redirects_3xx: {\n      count:               internalRedirectTrueCount,   // TRUE internal 3xx from crawl_overview\n      external_true_count: externalRedirectTrueCount,   // TRUE external 3xx from crawl_overview (e.g. 203)\n      grouped:             redirectGrouped,              // Display groups (top 30, for table)\n      external_grouped:    externalRedirectGrouped       // Display groups (top 15, for reference)\n    },\n    blocked_robots:  { count:getCount('response_codes_blocked_by_robots_txt.csv'), rows:robotsBlockedRows.slice(0,10), rule_summary:robotsRuleSummary },\n    redirect_chains: { count:getCount('redirect_chains.csv'), rows:chainSample },\n  },\n  images: {\n    missing_alt: { count:getCount('images_missing_alt_text.csv'), rows:missingAltRows },\n    oversized:   { count:getCount('images_over_100_kb.csv'),      rows:oversizedRows },\n  },\n  canonicals: {\n    missing:              { count:getCount('canonicals_missing.csv'), rows:canonicalMissingRows, indexable_missing:canonicalMissingIndexable },\n    multiple_conflicting: { count:getCount('canonicals_multiple_conflicting.csv'), rows:getSample('canonicals_multiple_conflicting.csv',10).map(r=>({url:r['Address']||''})) },\n    non_indexable:        { count:getCount('canonicals_nonindexable_canonical.csv'), rows:getSample('canonicals_nonindexable_canonical.csv',10).map(r=>({url:r['Address']||''})) },\n  },\n  directives: {\n    noindex: { count:noindexCount, rows:suspiciousNoindex.slice(0,25), suspicious_noindex:suspiciousNoindex },\n  },\n  content: {\n    exact_duplicates: { count:getCount('content_exact_duplicates.csv'), rows:getSample('content_exact_duplicates.csv',10).map(r=>({url:r['Address']||'',hash:r['Hash']||''})) },\n    near_duplicates:  { count:getCount('content_near_duplicates.csv'),  rows:getSample('content_near_duplicates.csv',10).map(r=>({url:r['Address']||'',similarity:r['Closest Similarity Match']||''})) },\n    low_content:      { count:getCount('content_low_content_pages.csv'), rows:lowContentRows },\n    // FIX 3: thin_content_300 \u2014 consistent 300-word threshold\n    thin_content_300: { count:thinContentPages.length, rows:thinContentPages.slice(0,15) },\n    word_count_buckets: wcBuckets,\n  },\n  security: {\n    http_pages:    { count:getCount('security_http_urls.csv'),    rows:httpPageRows },\n    mixed_content: { count:getCount('security_mixed_content.csv'),rows:getSample('security_mixed_content.csv',10).map(r=>({url:r['Address']||''})) },\n  },\n  redirect_reports: {\n    chains:           { count:getCount('redirect_chains.csv'),              rows:chainSample },\n    canonical_chains: { count:getCount('redirect_and_canonical_chains.csv'), rows:[] },\n  },\n  broken_link_sources:  broken_link_sources,   // Top 25 real 404/410 URLs with issue_type (Internal/External)\n  internal_403_links:   internal_403_links,     // YOUR OWN site pages returning 403 (WAF/Cloudflare blocking)\n  bot_blocked_links:    bot_blocked_links,       // Famous sites blocking crawlers \u2014 count + rows for display\n  bot_blocked_count:    bot_blocked_links.length,// Just the count \u2014 shown in note, no table needed\n  manual_check_links:   manual_check_links,      // Unknown external 403s \u2014 NEED manual check + table\n  orphan_pages:    { count:filteredOrphans.length, total_found:iStats.orphanPages.length, rows:filteredOrphans },\n  depth_analysis:  { buckets:depthBucketsForReport, deep_pages:iStats.orphanPages.filter(p=>p.depth>4).slice(0,10) },\n  robots_analysis: { count:getCount('response_codes_blocked_by_robots_txt.csv'), rule_summary:robotsRuleSummary, sample:robotsBlockedRows.slice(0,8) },\n  hreflang:        { count:getCount('hreflang_all_issues.csv'), rows:getRows('hreflang_all_issues.csv').slice(0,10) },\n  _files_found: Object.keys(files).map(name=>({ file:name, rows:files[name].totalRows, isEmpty:files[name].totalRows===0, skipped:files[name].skipped||false, error:files[name].error||null })),\n};\n\nreturn [{ json: audit }];"
      },
      "executeOnce": false,
      "retryOnFail": false,
      "typeVersion": 2,
      "alwaysOutputData": false
    },
    {
      "id": "d6616e98-7567-4dc3-b57c-eec48fabdaf1",
      "name": "Full Data Parser",
      "type": "n8n-nodes-base.code",
      "position": [
        2224,
        4496
      ],
      "parameters": {
        "jsCode": "// ================================================================\n// FULL DATA PARSER \u2014 Production v3.0\n// Node name in n8n: \"Full Data Parser\"\n// Input: binary ZIP from Compression node (same as SEO Audit Parser)\n// Output: complete audit object \u2192 Tab Builder \u2192 Excel File Builder\n//\n// CHANGES FROM v2.1:\n//   v3.0 - 403 split: internal / bot-blocked (famous) / manual-check\n//           Matches the same FAMOUS_DOMAINS list as the main parser\n//   v3.0 - broken_link_sources: ALL 404/410 with issue_type (Internal/External)\n//   v3.0 - broken404Count: unique 404/410/400 URLs (for display counts)\n//   v2.1 - System page filter on all issue files\n//   v2.1 - Internal redirects only (external removed)\n// ================================================================\n\nconst item   = $input.first();\nconst binary = item.binary;\nif (!binary) throw new Error('No binary data. Check Compression node is connected.');\n\nconst SKIP_FILES = new Set(['all_inlinks.csv', 'all_anchor_text.csv']);\n\n// \u2500\u2500 SYSTEM PAGE DETECTOR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction isSystemPage(url) {\n  if (!url) return false;\n  const u = url.toLowerCase();\n  if (u.includes('/search?'))            return true;\n  if (u.includes('/search/label/'))      return true;\n  if (u.includes('/share-widget'))       return true;\n  if (u.includes('?updated-max='))       return true;\n  if (u.includes('?max-results='))       return true;\n  if (u.includes('?reverse-paginate='))  return true;\n  if (/\\/search$/.test(u))               return true;\n  if (/\\/tag\\/[^/]/.test(u))             return true;\n  if (/\\/category\\/[^/]/.test(u))        return true;\n  if (/\\/author\\/[^/]/.test(u))          return true;\n  if (/\\/page\\/\\d/.test(u))              return true;\n  if (u.includes('/feed/'))              return true;\n  if (u.includes('?feed='))              return true;\n  if (/[?&]start=\\d/.test(u))            return true;\n  if (/[?&]paged=\\d/.test(u))            return true;\n  if (/[?&]page=\\d/.test(u))             return true;\n  if (u.includes('/wp-admin'))            return true;\n  if (u.includes('/wp-login'))            return true;\n  if (u.includes('/wp-json'))             return true;\n  if (u.includes('/xmlrpc'))             return true;\n  return false;\n}\n\n// \u2500\u2500 FAMOUS DOMAINS (same list as main SEO Audit Parser) \u2500\u2500\u2500\u2500\u2500\u2500\n// Sites that actively block all automated crawlers by design\nconst FAMOUS_DOMAINS = [\n  'twitter.com','x.com','t.co','facebook.com','instagram.com','tiktok.com',\n  'linkedin.com','pinterest.com','youtube.com','reddit.com','threads.net','snapchat.com',\n  'dawn.com','bbc.com','bbc.co.uk','cnn.com','reuters.com','bloomberg.com','forbes.com',\n  'nytimes.com','theguardian.com','wsj.com','ft.com','businessinsider.com','cnbc.com',\n  'independent.co.uk','dailymail.co.uk','express.co.uk','telegraph.co.uk',\n  'wikipedia.org','github.com','amazon.com','amazon.co.uk','apple.com','google.com',\n  'microsoft.com','cloudflare.com','archive.org','quora.com','medium.com','substack.com',\n  'gettyimages.com','shutterstock.com','alamy.com','istock.com','123rf.com',\n  'hotstar.com','netflix.com','hulu.com','disneyplus.com','spotify.com',\n  'ebay.com','alibaba.com','aliexpress.com','etsy.com','walmart.com',\n  'who.int','un.org','unesco.org','cswe.org','open.ac.uk','ox.ac.uk','harvard.edu',\n  'geo.tv','ary.digital','samaa.tv','dunyanews.tv','express.pk','jang.com.pk',\n  'thenews.com.pk','nation.com.pk','pakobserver.net','propakistani.pk',\n  'nasaspaceflight.com','donaldjtrump.com','truthsocial.com','glassdoor.com',\n  'trustpilot.com','yelp.com','tripadvisor.com','booking.com','airbnb.com',\n];\nconst isFamousDomain = d => FAMOUS_DOMAINS.some(fd => d === fd || d.endsWith('.' + fd));\n\n// \u2500\u2500 CSV PARSER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction parseCSVFromBuffer(buffer) {\n  const CHUNK = 65536;\n  const headers = [], rows = [];\n  let hParsed = false, rem = '';\n  let start = 0;\n  if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) start = 3;\n\n  function parseLine(line) {\n    const result = [];\n    let cur = '', inQ = false;\n    for (let i = 0; i < line.length; i++) {\n      const c = line[i];\n      if (c === '\"') {\n        if (inQ && line[i + 1] === '\"') { cur += '\"'; i++; }\n        else inQ = !inQ;\n      } else if (c === ',' && !inQ) { result.push(cur.trim()); cur = ''; }\n      else { cur += c; }\n    }\n    result.push(cur.trim());\n    return result;\n  }\n\n  for (let off = start; off < buffer.length; off += CHUNK) {\n    const chunk    = buffer.slice(off, Math.min(off + CHUNK, buffer.length)).toString('utf-8');\n    const combined = rem + chunk;\n    const nli      = combined.lastIndexOf('\\n');\n    if (nli === -1) { rem = combined; continue; }\n    const lines = combined.substring(0, nli + 1).split('\\n');\n    rem = combined.substring(nli + 1);\n    for (const raw of lines) {\n      const line = raw.replace(/\\r$/, '').trim();\n      if (!line) continue;\n      if (!hParsed) { parseLine(line).forEach(h => headers.push(h.replace(/^\\uFEFF/, ''))); hParsed = true; }\n      else { const vals = parseLine(line); const obj = {}; headers.forEach((h, i) => { obj[h] = (vals[i] ?? '').trim(); }); rows.push(obj); }\n    }\n  }\n  if (rem.trim() && hParsed) {\n    const vals = parseLine(rem.trim()); const obj = {};\n    headers.forEach((h, i) => { obj[h] = (vals[i] ?? '').trim(); }); rows.push(obj);\n  }\n  return { headers, rows, totalRows: rows.length };\n}\n\n// \u2500\u2500 LOAD ALL FILES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst files = {};\nfor (const key of Object.keys(binary)) {\n  const file = binary[key];\n  if (!file?.fileName) continue;\n  if (SKIP_FILES.has(file.fileName)) { files[file.fileName] = { headers:[], rows:[], totalRows:0, skipped:true }; continue; }\n  let buf;\n  try { buf = await this.helpers.getBinaryDataBuffer(0, key); }\n  catch (e) { files[file.fileName] = { headers:[], rows:[], totalRows:0, error:e.message }; continue; }\n  files[file.fileName] = parseCSVFromBuffer(buf);\n}\n\nconst getFile   = n => files[n] || { headers:[], rows:[], totalRows:0 };\nconst allRows   = n => getFile(n).rows;\nconst getCount  = n => getFile(n).totalRows;\nconst safeInt   = v => { const n = parseInt(v || '0'); return isNaN(n) ? 0 : n; };\nconst realRows  = fname => allRows(fname).filter(r => !isSystemPage(r['Address'] || ''));\nconst realCount = fname => realRows(fname).length;\n\n// \u2500\u2500 CRAWL OVERVIEW \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst ovFile = getFile('crawl_overview.csv');\nconst ovMeta = {};\nlet websiteUrl = '';\nlet currentSection = '';\n\nif (ovFile.headers?.length >= 2) {\n  const col0 = ovFile.headers[0], col1 = ovFile.headers[1];\n  if (col1?.startsWith('http')) websiteUrl = col1;\n  ovFile.rows.forEach(r => {\n    const k = (r[col0] || '').trim(), v = (r[col1] || '').trim();\n    if (k && v === '') { currentSection = k; }\n    else if (k && v !== '') {\n      ovMeta[k] = v;\n      if (currentSection) ovMeta[`${currentSection}::${k}`] = v;\n    }\n    if (k === 'Site Crawled' && v.startsWith('http')) websiteUrl = v;\n  });\n}\n\nfunction getDomain(url) {\n  try   { return new URL(url).hostname.toLowerCase().replace(/^www\\./, ''); }\n  catch { return (url || '').replace(/https?:\\/\\//, '').split('/')[0].toLowerCase().replace(/^www\\./, ''); }\n}\nconst siteDomain = websiteUrl ? getDomain(websiteUrl) : '';\n\nconst ov = (section, key) => {\n  if (section && ovMeta[`${section}::${key}`] !== undefined) return parseInt(ovMeta[`${section}::${key}`])||0;\n  return parseInt(ovMeta[key] || '0')||0;\n};\n\n// \u2500\u2500 BROKEN LINKS \u2014 FULL 403 SPLIT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Group all 4xx inlinks by destination URL\nconst brokenMap = {};\nallRows('client_error_(4xx)_inlinks.csv').forEach(r => {\n  const dest   = r['Destination'] || '';\n  const src    = r['Source']      || '';\n  const code   = r['Status Code'] || '';\n  const anchor = r['Anchor']      || '';\n  if (!dest || dest.includes('cdn-cgi')) return;\n\n  if (!brokenMap[dest]) {\n    const destDomain = getDomain(dest);\n    const isInternal = siteDomain && (destDomain === siteDomain || destDomain.endsWith('.' + siteDomain));\n    brokenMap[dest] = {\n      broken_url:   dest,\n      status_code:  code,\n      anchor_text:  anchor,\n      source_pages: [],\n      total_inlinks: 0,\n      issue_type:   isInternal ? 'Internal' : 'External',\n      domain:       destDomain,\n    };\n  }\n  brokenMap[dest].total_inlinks++;\n  if (src && !brokenMap[dest].source_pages.includes(src)) brokenMap[dest].source_pages.push(src);\n});\n\nconst REAL_BROKEN = new Set(['404', '410', '400']);\nconst BOT_CODES   = new Set(['403', '429', '401', '999']);\n\n// ALL 404/410/400 unique broken URLs (internal + external)\nconst allBroken404 = Object.values(brokenMap)\n  .filter(b => REAL_BROKEN.has(b.status_code))\n  .sort((a, b) => b.total_inlinks - a.total_inlinks);\n\n// broken_link_sources = ALL real broken URLs with issue_type field\nconst broken_link_sources = allBroken404.map(b => ({\n  broken_url:    b.broken_url,\n  status_code:   b.status_code,\n  anchor_text:   b.anchor_text,\n  total_inlinks: b.total_inlinks,\n  source_pages:  b.source_pages.slice(0, 5),\n  issue_type:    b.issue_type === 'Internal' ? 'Internal 404' : 'External 404',\n}));\n\n// Count of unique 404-type URLs (for display)\nconst broken404Count = allBroken404.length;\n\n// 403 THREE-WAY SPLIT:\nconst allBroken403 = Object.values(brokenMap).filter(b => BOT_CODES.has(b.status_code));\n\n// 1. Internal 403 = YOUR OWN site pages returning 403 (WAF/Cloudflare blocking)\nconst internal_403_links = allBroken403\n  .filter(b => siteDomain && getDomain(b.broken_url).includes(siteDomain))\n  .map(b => ({\n    broken_url:    b.broken_url,\n    status_code:   b.status_code,\n    total_inlinks: b.total_inlinks,\n    source_pages:  b.source_pages.slice(0, 3),\n  }));\n\nconst external403 = allBroken403.filter(b => !(siteDomain && getDomain(b.broken_url).includes(siteDomain)));\n\n// 2. Famous bot-blocked external = known crawler-blocking platforms (informational only)\nconst bot_blocked_links = external403\n  .filter(b => isFamousDomain(getDomain(b.broken_url)))\n  .map(b => ({\n    broken_url:    b.broken_url,\n    status_code:   b.status_code,\n    total_inlinks: b.total_inlinks,\n    domain:        getDomain(b.broken_url),\n  }));\nconst bot_blocked_count = bot_blocked_links.length;\n\n// 3. Unknown external 403 = need manual check (open in browser to verify)\nconst manual_check_links = external403\n  .filter(b => !isFamousDomain(getDomain(b.broken_url)))\n  .map(b => ({\n    broken_url:    b.broken_url,\n    status_code:   b.status_code,\n    total_inlinks: b.total_inlinks,\n    source_pages:  b.source_pages.slice(0, 3),\n  }));\n\n// \u2500\u2500 REDIRECTS \u2014 INTERNAL ONLY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst internalRedirectRows = allRows('response_codes_redirection_(3xx).csv').filter(r => {\n  const from = r['Address'] || '';\n  if (!from || isSystemPage(from)) return false;\n  const fromD = getDomain(from);\n  return siteDomain && (fromD.includes(siteDomain) || siteDomain.includes(fromD));\n}).map(r => ({\n  url:         r['Address'] || '',\n  redirect_to: r['Redirect URL'] || r['Redirect URI'] || '',\n  code:        r['Status Code'] || '',\n  inlinks:     safeInt(r['Inlinks']),\n}));\n\n// \u2500\u2500 H1 / TITLE DUPLICATES (real pages only) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst h1TextMap = {};\nrealRows('h1_duplicate.csv').forEach(r => {\n  const h = r['H1-1'] || '', u = r['Address'] || '';\n  if (!h) return;\n  if (!h1TextMap[h]) h1TextMap[h] = { h1: h, urls: [] };\n  h1TextMap[h].urls.push(u);\n});\n\nconst titleTextMap = {};\nrealRows('page_titles_duplicate.csv').forEach(r => {\n  const t = r['Title 1'] || '', u = r['Address'] || '';\n  if (!t) return;\n  if (!titleTextMap[t]) titleTextMap[t] = { title: t, urls: [] };\n  titleTextMap[t].urls.push(u);\n});\n\n// \u2500\u2500 CANONICALS (real + indexable) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst canonicalMissingFull = realRows('canonicals_missing.csv')\n  .filter(r => r['Indexability'] === 'Indexable')\n  .map(r => ({ url: r['Address'] || '', indexability: r['Indexability'] || '' }));\n\n// \u2500\u2500 NOINDEX (real pages, filter intentional) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst INTENTIONAL = ['/tag/','/author/','/page/','/feed/','/wp-','sitemap','login','admin','cart','checkout','wp-json','xmlrpc'];\nconst noindexFull = realRows('directives_noindex.csv')\n  .map(r => ({ url: r['Address'] || '', directive: r['Meta Robots 1'] || '' }))\n  .filter(r => !INTENTIONAL.some(p => r.url.toLowerCase().includes(p)));\n\n// \u2500\u2500 SYSTEM PAGES SUMMARY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst allInternalRows = allRows('internal_html.csv');\nconst totalSystemPages = allInternalRows.filter(r => isSystemPage(r['Address'] || '')).length;\nconst systemTypeMap = {};\nallInternalRows.filter(r => isSystemPage(r['Address'] || '')).forEach(r => {\n  const u = r['Address'].toLowerCase();\n  const type = u.includes('/search/label/') ? 'Label/Tag Archive'\n             : u.includes('/search') ? 'Search Results'\n             : u.includes('/share-widget') ? 'Share Widget'\n             : u.includes('?updated-max=') || u.includes('?start=') || u.includes('?max-results=') ? 'Paginated Pages'\n             : u.includes('/tag/') ? 'Tag Archive'\n             : u.includes('/category/') ? 'Category Archive'\n             : u.includes('/feed/') ? 'Feed / RSS'\n             : 'Other System';\n  systemTypeMap[type] = (systemTypeMap[type] || 0) + 1;\n});\n\n// \u2500\u2500 ASSEMBLE OUTPUT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst fullAudit = {\n  meta: {\n    website:            websiteUrl,\n    crawl_date:         ovMeta['Date'] || new Date().toISOString().split('T')[0],\n    total_urls_crawled: getCount('internal_html.csv'),\n    system_pages_found: totalSystemPages,\n    // Crawl overview totals for summary tab\n    co_total_crawled:   ov('Summary','Total URLs Crawled') || getCount('internal_html.csv'),\n    co_indexable:       ov('Summary','Total Internal Indexable URLs'),\n    co_non_indexable:   ov('Summary','Total Internal Non-Indexable URLs'),\n    co_internal_all:    ov('Internal','All'),\n    co_external_all:    ov('External','All'),\n    co_internal_4xx:    ov('Response Codes','Internal Client Error (4xx)'),\n    co_external_4xx:    ov('Response Codes','External Client Error (4xx)'),\n    co_internal_3xx:    ov('Response Codes','Internal Redirection (3xx)'),\n    co_external_3xx:    ov('Response Codes','External Redirection (3xx)'),\n  },\n\n  page_titles: {\n    missing:   { count: realCount('page_titles_missing.csv'),            rows: realRows('page_titles_missing.csv').map(r => ({ url: r['Address'] || '' })) },\n    too_long:  { count: realCount('page_titles_over_60_characters.csv'), rows: realRows('page_titles_over_60_characters.csv').map(r => ({ url: r['Address'] || '', title: r['Title 1'] || '', length: safeInt(r['Title 1 Length']) })).sort((a, b) => b.length - a.length) },\n    too_short: { count: realCount('page_titles_below_30_characters.csv') || 0, rows: realRows('page_titles_below_30_characters.csv').map(r => ({ url: r['Address'] || '' })) },\n    duplicate: { count: realCount('page_titles_duplicate.csv'), grouped: Object.values(titleTextMap).sort((a, b) => b.urls.length - a.urls.length).map(g => ({ title: g.title, page_count: g.urls.length, all_urls: g.urls })) },\n  },\n\n  meta_descriptions: {\n    missing:   { count: realCount('meta_description_missing.csv'),             rows: realRows('meta_description_missing.csv').map(r => ({ url: r['Address'] || '' })) },\n    too_long:  { count: realCount('meta_description_over_155_characters.csv'), rows: realRows('meta_description_over_155_characters.csv').map(r => ({ url: r['Address'] || '', meta: r['Meta Description 1'] || '', length: safeInt(r['Meta Description 1 Length']) })).sort((a, b) => b.length - a.length) },\n    too_short: { count: realCount('meta_description_below_70_characters.csv'), rows: realRows('meta_description_below_70_characters.csv').map(r => ({ url: r['Address'] || '' })) },\n  },\n\n  headings: {\n    h1_missing:   { count: realCount('h1_missing.csv'),   rows: realRows('h1_missing.csv').map(r => ({ url: r['Address'] || '' })) },\n    h1_duplicate: { count: realCount('h1_duplicate.csv'), grouped: Object.values(h1TextMap).sort((a, b) => b.urls.length - a.urls.length).map(g => ({ h1: g.h1, page_count: g.urls.length, all_urls: g.urls })) },\n    h1_multiple:  { count: realCount('h1_multiple.csv'),  rows: realRows('h1_multiple.csv').map(r => ({ url: r['Address'] || '', h1_first: r['H1-1'] || '', h1_second: r['H1-2'] || '' })) },\n    h2_missing:   { count: realCount('h2_missing.csv'),   rows: realRows('h2_missing.csv').map(r => ({ url: r['Address'] || '' })) },\n  },\n\n  response_codes: {\n    // 4xx rows from response_codes file (for the response codes tab)\n    errors_4xx:     { count: realCount('response_codes_client_error_(4xx).csv'), rows: realRows('response_codes_client_error_(4xx).csv').map(r => ({ url: r['Address'] || '', code: r['Status Code'] || '', status: r['Status'] || '', inlinks: safeInt(r['Inlinks']) })).sort((a, b) => b.inlinks - a.inlinks) },\n    errors_5xx:     { count: realCount('response_codes_server_error_(5xx).csv'), rows: realRows('response_codes_server_error_(5xx).csv').map(r => ({ url: r['Address'] || '', code: r['Status Code'] || '', status: r['Status'] || '', inlinks: safeInt(r['Inlinks']) })) },\n    redirects_3xx:  { count: internalRedirectRows.length, rows: internalRedirectRows },\n    blocked_robots: { count: getCount('response_codes_blocked_by_robots_txt.csv'), rows: allRows('response_codes_blocked_by_robots_txt.csv').map(r => ({ url: r['Address'] || '', robots_line: r['Matched Robots.txt Line'] || '' })) },\n  },\n\n  redirect_reports: {\n    chains: {\n      count: getCount('redirect_chains.csv'),\n      rows: allRows('redirect_chains.csv').map(r => ({\n        source: r['Source'] || r['Address'] || Object.values(r)[0] || '',\n        final:  r['Final Address'] || r['Final URL'] || '',\n        hops:   safeInt(r['Number of Redirects'] || r['Hops'] || '2'),\n      }))\n    },\n  },\n\n  images: {\n    missing_alt: { count: realCount('images_missing_alt_text.csv'), rows: realRows('images_missing_alt_text.csv').map(r => ({ url: r['Address'] || '', size_kb: Math.round(safeInt(r['Size (bytes)']) / 1024), inlinks: safeInt(r['IMG Inlinks']) })) },\n    oversized:   { count: realCount('images_over_100_kb.csv'),      rows: realRows('images_over_100_kb.csv').map(r => ({ url: r['Address'] || '', size_kb: Math.round(safeInt(r['Size (bytes)']) / 1024), inlinks: safeInt(r['IMG Inlinks']) })).sort((a, b) => b.size_kb - a.size_kb) },\n  },\n\n  canonicals: {\n    missing:              { count: canonicalMissingFull.length, rows: canonicalMissingFull },\n    multiple_conflicting: { count: realCount('canonicals_multiple_conflicting.csv'), rows: realRows('canonicals_multiple_conflicting.csv').map(r => ({ url: r['Address'] || '' })) },\n    non_indexable:        { count: realCount('canonicals_nonindexable_canonical.csv'), rows: realRows('canonicals_nonindexable_canonical.csv').map(r => ({ url: r['Address'] || '' })) },\n  },\n\n  directives: {\n    noindex: { count: noindexFull.length, rows: noindexFull },\n  },\n\n  content: {\n    exact_duplicates: { count: realCount('content_exact_duplicates.csv'), rows: realRows('content_exact_duplicates.csv').map(r => ({ url: r['Address'] || '', hash: r['Hash'] || '' })) },\n    near_duplicates:  { count: realCount('content_near_duplicates.csv'),  rows: realRows('content_near_duplicates.csv').map(r => ({ url: r['Address'] || '', similarity: r['Closest Similarity Match'] || '' })) },\n    low_content:      { count: realCount('content_low_content_pages.csv'), rows: realRows('content_low_content_pages.csv').map(r => ({ url: r['Address'] || '', word_count: safeInt(r['Word Count']) })).sort((a, b) => a.word_count - b.word_count) },\n  },\n\n  security: {\n    http_pages:    { count: realCount('security_http_urls.csv'),    rows: realRows('security_http_urls.csv').map(r => ({ url: r['Address'] || '', status_code: r['Status Code'] || '' })) },\n    mixed_content: { count: getCount('security_mixed_content.csv'), rows: allRows('security_mixed_content.csv').map(r => ({ url: r['Address'] || '' })) },\n  },\n\n  // \u2500\u2500 4xx BROKEN LINK DATA (all categories) \u2500\u2500\n  broken_link_sources:   broken_link_sources,  // ALL 404/410/400 URLs with issue_type\n  broken404Count:        broken404Count,        // Unique 404-type URLs (for display counts)\n  internal_403_links:    internal_403_links,    // Your own site returning 403 (WAF/CF)\n  bot_blocked_links:     bot_blocked_links,     // Famous platforms blocking crawlers\n  bot_blocked_count:     bot_blocked_count,     // Count of famous-domain 403s\n  manual_check_links:    manual_check_links,    // Unknown external 403s \u2014 need manual check\n\n  system_pages: {\n    total_found:    totalSystemPages,\n    by_type:        Object.entries(systemTypeMap).sort((a, b) => b[1] - a[1]).map(([type, count]) => ({ type, count })),\n    recommendation: totalSystemPages > 0\n      ? `${totalSystemPages} system-generated pages detected. Add noindex meta tags or robots.txt Disallow rules for these URL patterns to improve crawl budget.`\n      : null,\n  },\n};\n\nreturn [{ json: fullAudit }];"
      },
      "typeVersion": 2
    },
    {
      "id": "288b5ce6-7a35-4df2-b24d-92f08a362cc5",
      "name": "Tab Builder",
      "type": "n8n-nodes-base.code",
      "position": [
        2800,
        4496
      ],
      "parameters": {
        "jsCode": "// ================================================================\n// TAB BUILDER \u2014 Production v3.0\n// Node name in n8n: \"Tab Builder\"\n// Input:  Full Data Parser output\n// Output: sheetData object \u2192 Excel File Builder\n//\n// TABS PRODUCED (only if data exists):\n//  1.  Summary              \u2014 overview of all issues with counts + status\n//  2.  Broken Pages (404)   \u2014 all 404/410 URLs (internal + external) with type badge\n//  3.  Internal 403         \u2014 your own site pages blocking the crawler (WAF/CF)\n//  4.  Check These 403      \u2014 unknown external 403s (need browser verification)\n//  5.  Bot-Blocked 403      \u2014 famous platforms blocking crawlers (info only)\n//  6.  Server Errors (5xx)  \u2014 server crash URLs\n//  7.  HTTP Insecure         \u2014 HTTP pages needing HTTPS redirect\n//  8.  Page Titles           \u2014 missing, too long, too short, duplicate\n//  9.  Meta Descriptions     \u2014 missing, too long, too short\n//  10. H1 and Headings       \u2014 missing, duplicate, multiple, missing H2\n//  11. Images                \u2014 missing alt, oversized\n//  12. Redirects (Internal)  \u2014 internal 3xx + redirect chains\n//  13. Canonicals            \u2014 missing, conflicting, non-indexable\n//  14. Indexation Issues     \u2014 noindex, thin content, near-duplicates\n//  15. Exact Duplicates      \u2014 exact duplicate pages\n//  16. Security              \u2014 HTTP + mixed content\n//  17. Robots Blocked        \u2014 pages blocked by robots.txt\n//  18. System Pages          \u2014 CMS auto-generated pages (excluded from other tabs)\n// ================================================================\n\nconst audit  = $input.first().json;\nconst domain = (audit.meta.website || '').replace(/https?:\\/\\//, '').replace(/\\/$/, '');\nconst date   = audit.meta.crawl_date || new Date().toISOString().split('T')[0];\n\n// Clean cell value \u2014 no newlines, no tabs, max 1000 chars\nconst clean = v => String(v ?? '').replace(/[\\r\\n]+/g, ' ').replace(/\\t/g, ' ').trim().substring(0, 1000);\n\nfunction makeTab(name, headers, rows) {\n  if (!rows || rows.length === 0) return null;\n  return { name, headers, rows };\n}\n\nconst tabs = [];\n\n// ================================================================\n// TAB 1: SUMMARY\n// ================================================================\nconst internal403Count = audit.internal_403_links?.length || 0;\nconst manualCheck403   = audit.manual_check_links?.length || 0;\nconst botBlocked403    = audit.bot_blocked_count || 0;\nconst broken404Count   = audit.broken404Count || audit.broken_link_sources?.length || 0;\n\ntabs.push({\n  name: 'Summary',\n  headers: ['METRIC', 'COUNT', 'STATUS', 'NOTES'],\n  rows: [\n    ['Website',              domain,                     '',   ''],\n    ['Audit Date',           date,                       '',   ''],\n    ['Total URLs Crawled',   audit.meta.co_total_crawled || audit.meta.total_urls_crawled || 0, '', ''],\n    ['Indexable Pages',      audit.meta.co_indexable     || 0, '', 'From Screaming Frog crawl overview'],\n    ['Non-Indexable',        audit.meta.co_non_indexable || 0, '', 'Blocked from Google'],\n    ['Internal URLs',        audit.meta.co_internal_all  || 0, '', 'All internal URL types'],\n    ['External URLs',        audit.meta.co_external_all  || 0, '', 'External links crawled'],\n    ['System Pages Excluded',audit.meta.system_pages_found || 0, '', 'See System Pages tab'],\n    ['', '', '', ''],\n\n    ['\u2500\u2500 CRITICAL ISSUES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', '', '', ''],\n    ['Broken Pages (404/410)',          broken404Count,                              broken404Count > 0           ? 'CRITICAL' : 'OK', 'See Broken Pages tab \u2014 301 redirect or remove links'],\n    ['Internal 403 Errors (Your Site)', internal403Count,                            internal403Count > 0         ? 'CRITICAL' : 'OK', 'See Internal 403 tab \u2014 WAF/Cloudflare blocking crawler'],\n    ['Server Errors (5xx)',             audit.response_codes?.errors_5xx?.count || 0, (audit.response_codes?.errors_5xx?.count || 0) > 0 ? 'CRITICAL' : 'OK', 'Check server/plugin logs immediately'],\n    ['Missing H1 Headings',             audit.headings?.h1_missing?.count || 0,       (audit.headings?.h1_missing?.count || 0) > 0 ? 'CRITICAL' : 'OK', 'Add H1 to every content page'],\n    ['Missing Page Titles',             audit.page_titles?.missing?.count || 0,        (audit.page_titles?.missing?.count || 0) > 0 ? 'CRITICAL' : 'OK', 'Add unique 50\u201360 char title'],\n    ['HTTP Insecure Pages',             audit.security?.http_pages?.count || 0,        (audit.security?.http_pages?.count || 0) > 0 ? 'CRITICAL' : 'OK', '301 redirect all HTTP pages to HTTPS'],\n    ['Exact Duplicate Pages',           audit.content?.exact_duplicates?.count || 0,   (audit.content?.exact_duplicates?.count || 0) > 0 ? 'CRITICAL' : 'OK', 'Add canonical or 301 redirect'],\n    ['', '', '', ''],\n\n    ['\u2500\u2500 WARNING ISSUES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', '', '', ''],\n    ['External 403 (Check These)',       manualCheck403,                               manualCheck403 > 0       ? 'Check Manually' : 'OK', 'See Check These 403 tab \u2014 open each in browser'],\n    ['Missing Meta Descriptions',        audit.meta_descriptions?.missing?.count || 0,  (audit.meta_descriptions?.missing?.count || 0) > 0 ? 'Fix Soon' : 'OK', '140\u2013155 chars with keyword + CTA'],\n    ['Page Titles Too Long (>60)',        audit.page_titles?.too_long?.count || 0,       (audit.page_titles?.too_long?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Shorten to under 60 chars'],\n    ['Duplicate H1 Headings',            audit.headings?.h1_duplicate?.count || 0,      (audit.headings?.h1_duplicate?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Each page needs unique H1'],\n    ['Multiple H1 on Same Page',         audit.headings?.h1_multiple?.count || 0,       (audit.headings?.h1_multiple?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Usually a template fix'],\n    ['Images Missing Alt Text',          audit.images?.missing_alt?.count || 0,         (audit.images?.missing_alt?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Add descriptive alt attribute'],\n    ['Oversized Images (>100KB)',         audit.images?.oversized?.count || 0,           (audit.images?.oversized?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Compress and convert to WebP'],\n    ['Redirect Chains',                  audit.redirect_reports?.chains?.count || 0,    (audit.redirect_reports?.chains?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Update links to final URL'],\n    ['Internal Redirects',               audit.response_codes?.redirects_3xx?.count || 0, (audit.response_codes?.redirects_3xx?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Update internal links to skip redirect'],\n    ['Missing Canonical Tags',           audit.canonicals?.missing?.count || 0,         (audit.canonicals?.missing?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Add self-referencing canonical'],\n    ['Thin Content (<300 words)',         audit.content?.low_content?.count || 0,        (audit.content?.low_content?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Expand or consolidate pages'],\n    ['Near-Duplicate Pages',             audit.content?.near_duplicates?.count || 0,    (audit.content?.near_duplicates?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Differentiate or canonicalise'],\n    ['Mixed Content (HTTP on HTTPS)',     audit.security?.mixed_content?.count || 0,     (audit.security?.mixed_content?.count || 0) > 0 ? 'Fix Soon' : 'OK', 'Update HTTP resource URLs to HTTPS'],\n    ['', '', '', ''],\n\n    ['\u2500\u2500 INFO ONLY \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', '', '', ''],\n    ['Known Bot-Blocking Platforms (403)', botBlocked403, 'No Action', 'Famous sites (Twitter, Wikipedia etc.) that block crawlers \u2014 links work in browser'],\n    ['Internal 4xx Total (SF)',             audit.meta.co_internal_4xx || 0, 'Reference', 'From Screaming Frog crawl overview \u2014 all 4xx on your server'],\n    ['External 4xx Total (SF)',             audit.meta.co_external_4xx || 0, 'Reference', 'From Screaming Frog \u2014 external URLs you link to that are broken'],\n    ['External Redirects Total (SF)',       audit.meta.co_external_3xx || 0, 'Reference', 'External URLs you link to that redirect \u2014 not your issue to fix'],\n    ['', '', '', ''],\n\n    ['\u2500\u2500 HOW TO USE THIS FILE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', '', '', ''],\n    ['Status Column',    'Change TODO \u2192 IN PROGRESS or DONE as you fix each URL', '', ''],\n    ['System Pages',     'See the System Pages tab for CMS auto-generated pages info', '', ''],\n    ['PDF Report',       'See the PDF report for full analysis, priorities, and explanations', '', ''],\n    ['Priority Order',   'Fix CRITICAL first, then Fix Soon, then Info Only', '', ''],\n  ]\n});\n\n// ================================================================\n// TAB 2: BROKEN PAGES (404/410/400) \u2014 Internal + External\n// ================================================================\nconst brokenRows = (audit.broken_link_sources || []).map((r, i) => [\n  i + 1,\n  r.issue_type === 'Internal 404' ? 'INTERNAL' : 'EXTERNAL',\n  clean(r.broken_url),\n  r.status_code || '404',\n  r.total_inlinks || 0,\n  clean((r.source_pages || []).slice(0, 5).join(' | ')),\n  r.issue_type === 'Internal 404'\n    ? '301 redirect to nearest live page OR remove all internal links pointing to this URL'\n    : 'Update your link to point to a working alternative URL on that site',\n  'TODO'\n]);\nconst t2 = makeTab('Broken Pages (404)', ['#', 'Type', 'Broken URL', 'Code', 'Inlinks', 'Pages Linking To It', 'Fix Action', 'Status'], brokenRows);\nif (t2) tabs.push(t2);\n\n// ================================================================\n// TAB 3: INTERNAL 403 \u2014 Your own site blocking crawler\n// ================================================================\nconst int403Rows = (audit.internal_403_links || []).map((r, i) => [\n  i + 1,\n  clean(r.broken_url),\n  r.status_code || '403',\n  r.total_inlinks || 0,\n  clean((r.source_pages || []).slice(0, 3).join(' | ')),\n  'Check Cloudflare/WAF Bot Management \u2014 if Googlebot is also blocked, these pages cannot be indexed',\n  'TODO'\n]);\nconst t3 = makeTab('Internal 403 (YOUR Site)', ['#', 'Your Page (Blocked)', 'Code', 'Inlinks', 'Linked From', 'Fix Action', 'Status'], int403Rows);\nif (t3) tabs.push(t3);\n\n// ================================================================\n// TAB 4: MANUAL CHECK 403 \u2014 Unknown external 403s\n// ================================================================\nconst manualCheckRows = (audit.manual_check_links || []).map((r, i) => [\n  i + 1,\n  clean(r.broken_url),\n  r.status_code || '403',\n  r.total_inlinks || 0,\n  clean((r.source_pages || []).slice(0, 2).join(' | ')),\n  'Open URL in browser (Incognito). If it loads = bot-blocking only, link is fine. If 403 in browser too = link is broken, fix it.',\n  'TODO'\n]);\nconst t4 = makeTab('Check These 403', ['#', 'External URL', 'Code', 'Inlinks', 'Linked From', 'Action Required', 'Status'], manualCheckRows);\nif (t4) tabs.push(t4);\n\n// ================================================================\n// TAB 5: BOT-BLOCKED 403 \u2014 Famous platforms (info only, no fixes)\n// ================================================================\nconst botBlockedRows = (audit.bot_blocked_links || []).map((r, i) => [\n  i + 1,\n  clean(r.broken_url),\n  r.domain || '',\n  r.status_code || '403',\n  r.total_inlinks || 0,\n  'No action needed \u2014 this platform blocks all automated crawlers. Link works fine in a browser.'\n]);\nconst t5 = makeTab('Bot-Blocked 403 (Info)', ['#', 'External URL', 'Platform Domain', 'Code', 'Inlinks', 'Note'], botBlockedRows);\nif (t5) tabs.push(t5);\n\n// ================================================================\n// TAB 6: SERVER ERRORS (5xx)\n// ================================================================\nconst e5xxRows = (audit.response_codes?.errors_5xx?.rows || []).map((r, i) => [\n  i + 1, clean(r.url), r.code || '5xx', r.status || '', r.inlinks || 0,\n  'Check server error logs immediately \u2014 likely PHP memory, database, or plugin failure', 'TODO'\n]);\nconst t6 = makeTab('Server Errors (5xx)', ['#', 'URL', 'Code', 'Status', 'Inlinks', 'Fix Action', 'Status'], e5xxRows);\nif (t6) tabs.push(t6);\n\n// ================================================================\n// TAB 7: HTTP INSECURE PAGES\n// ================================================================\nconst httpRows = (audit.security?.http_pages?.rows || []).map((r, i) => [\n  i + 1, clean(r.url), r.status_code || '', '301 redirect to HTTPS version and verify SSL certificate covers all subdomains', 'TODO'\n]);\nconst t7 = makeTab('HTTP Insecure', ['#', 'URL', 'Status Code', 'Fix Action', 'Status'], httpRows);\nif (t7) tabs.push(t7);\n\n// ================================================================\n// TAB 8: PAGE TITLES\n// ================================================================\nconst titleRows = [];\nlet ti = 1;\n(audit.page_titles?.missing?.rows || []).forEach(r => {\n  titleRows.push([ti++, clean(r.url), '(missing)', 0, 'Missing', 'Add unique 50\u201360 char title with primary keyword near the start', 'TODO']);\n});\n(audit.page_titles?.too_long?.rows || []).forEach(r => {\n  titleRows.push([ti++, clean(r.url), clean(r.title), r.length || '?', 'Too Long (>60)', 'Shorten to under 60 chars \u2014 keep keyword within first 50', 'TODO']);\n});\n(audit.page_titles?.too_short?.rows || []).forEach(r => {\n  titleRows.push([ti++, clean(r.url), '(short title)', '', 'Too Short (<30)', 'Expand title to 40\u201360 chars with primary keyword and brand name', 'TODO']);\n});\n(audit.page_titles?.duplicate?.grouped || []).forEach(g => {\n  (g.all_urls || []).forEach(url => {\n    titleRows.push([ti++, clean(url), clean(g.title), '', 'Duplicate', `Write unique title \u2014 current shared: \"${clean(g.title).substring(0, 50)}\"`, 'TODO']);\n  });\n});\nconst t8 = makeTab('Page Titles', ['#', 'URL', 'Current Title', 'Length', 'Issue', 'Fix Action', 'Status'], titleRows);\nif (t8) tabs.push(t8);\n\n// ================================================================\n// TAB 9: META DESCRIPTIONS\n// ================================================================\nconst metaRows = [];\nlet mi = 1;\n(audit.meta_descriptions?.missing?.rows || []).forEach(r => {\n  metaRows.push([mi++, clean(r.url), '(missing)', 0, 'Missing', 'Write 140\u2013155 char description with keyword and clear CTA', 'TODO']);\n});\n(audit.meta_descriptions?.too_long?.rows || []).forEach(r => {\n  metaRows.push([mi++, clean(r.url), clean(r.meta), r.length || '?', 'Too Long (>155)', 'Trim to under 155 chars \u2014 keep CTA within first 140', 'TODO']);\n});\n(audit.meta_descriptions?.too_short?.rows || []).forEach(r => {\n  metaRows.push([mi++, clean(r.url), '(short meta)', '', 'Too Short (<70)', 'Expand to 140\u2013155 chars with keyword and CTA', 'TODO']);\n});\nconst t9 = makeTab('Meta Descriptions', ['#', 'URL', 'Current Meta', 'Length', 'Issue', 'Fix Action', 'Status'], metaRows);\nif (t9) tabs.push(t9);\n\n// ================================================================\n// TAB 10: H1 AND HEADINGS\n// ================================================================\nconst h1Rows = [];\nlet hi = 1;\n(audit.headings?.h1_missing?.rows || []).forEach(r => {\n  h1Rows.push([hi++, clean(r.url), '(missing)', 'Missing H1', 'Add exactly one H1 describing page topic \u2014 include primary keyword', 'TODO']);\n});\n(audit.headings?.h1_duplicate?.grouped || []).forEach(g => {\n  (g.all_urls || []).forEach(url => {\n    h1Rows.push([hi++, clean(url), clean(g.h1), 'Duplicate H1', `Write unique H1 \u2014 current shared: \"${clean(g.h1).substring(0, 50)}\"`, 'TODO']);\n  });\n});\n(audit.headings?.h1_multiple?.rows || []).forEach(r => {\n  h1Rows.push([hi++, clean(r.url), clean(r.h1_second), 'Multiple H1s on Page', `Remove or change to H2: \"${clean(r.h1_second).substring(0, 50)}\" \u2014 usually a template fix`, 'TODO']);\n});\n(audit.headings?.h2_missing?.rows || []).forEach(r => {\n  h1Rows.push([hi++, clean(r.url), '(missing)', 'Missing H2', 'Add H2 subheadings to divide content into logical sections', 'TODO']);\n});\nconst t10 = makeTab('H1 and Headings', ['#', 'URL', 'Heading Text', 'Issue Type', 'Fix Action', 'Status'], h1Rows);\nif (t10) tabs.push(t10);\n\n// ================================================================\n// TAB 11: IMAGES\n// ================================================================\nconst imgRows = [];\nlet ii = 1;\n(audit.images?.missing_alt?.rows || []).forEach(r => {\n  imgRows.push([ii++, clean(r.url), r.size_kb || 0, r.inlinks || 0, 'Missing Alt Text', 'Add descriptive alt text explaining what image shows \u2014 use keyword where natural', 'TODO']);\n});\n(audit.images?.oversized?.rows || []).forEach(r => {\n  imgRows.push([ii++, clean(r.url), r.size_kb || 0, r.inlinks || 0, `Oversized (${r.size_kb || 0}KB)`, 'Compress with TinyPNG/Squoosh, convert to WebP, target under 80KB', 'TODO']);\n});\nconst t11 = makeTab('Images', ['#', 'Image URL', 'Size (KB)', 'Pages Using It', 'Issue', 'Fix Action', 'Status'], imgRows);\nif (t11) tabs.push(t11);\n\n// ================================================================\n// TAB 12: INTERNAL REDIRECTS (3xx chains)\n// ================================================================\nconst rdRows = [];\nlet ri = 1;\n(audit.response_codes?.redirects_3xx?.rows || []).forEach(r => {\n  rdRows.push([ri++, clean(r.url), clean(r.redirect_to), r.code || '301', r.inlinks || 0,\n    'Update all internal links on your site to point directly to the final URL \u2014 skip the redirect', 'TODO']);\n});\n(audit.redirect_reports?.chains?.rows || []).forEach(r => {\n  rdRows.push([ri++, clean(r.source), clean(r.final), '301 chain', 0,\n    `Fix ${r.hops || 2}-hop redirect chain \u2014 update source link to point directly to: ${clean(r.final)}`, 'TODO']);\n});\nconst t12 = makeTab('Redirects (Internal)', ['#', 'From URL (Your Site)', 'To URL', 'Code', 'Inlinks', 'Fix Action', 'Status'], rdRows);\nif (t12) tabs.push(t12);\n\n// ================================================================\n// TAB 13: CANONICALS\n// ================================================================\nconst canonRows = [];\nlet ci2 = 1;\n(audit.canonicals?.missing?.rows || []).forEach(r => {\n  canonRows.push([ci2++, clean(r.url), 'Missing Canonical', 'Add self-referencing <link rel=\"canonical\" href=\"THIS_URL\"> in <head>', 'TODO']);\n});\n(audit.canonicals?.multiple_conflicting?.rows || []).forEach(r => {\n  canonRows.push([ci2++, clean(r.url), 'Multiple Conflicting Canonicals', 'Remove duplicate canonical tags \u2014 keep only ONE self-referencing canonical', 'TODO']);\n});\n(audit.canonicals?.non_indexable?.rows || []).forEach(r => {\n  canonRows.push([ci2++, clean(r.url), 'Canonical Points to Non-Indexable URL', 'Update canonical to point to an indexable live 200 page', 'TODO']);\n});\nconst t13 = makeTab('Canonicals', ['#', 'URL', 'Issue', 'Fix Action', 'Status'], canonRows);\nif (t13) tabs.push(t13);\n\n// ================================================================\n// TAB 14: INDEXATION ISSUES (noindex, thin, near-duplicate)\n// ================================================================\nconst idxRows = [];\nlet xi = 1;\n(audit.directives?.noindex?.rows || []).forEach(r => {\n  idxRows.push([xi++, clean(r.url), r.directive || '', 'Suspicious Noindex', 'Verify if intentional \u2014 remove noindex if this page should rank in Google', 'TODO']);\n});\n(audit.content?.low_content?.rows || []).forEach(r => {\n  idxRows.push([xi++, clean(r.url), r.word_count || 0, 'Thin Content (<300 words)', 'Expand to 600+ words with useful content, or 301 redirect to a related stronger page', 'TODO']);\n});\n(audit.content?.near_duplicates?.rows || []).forEach(r => {\n  idxRows.push([xi++, clean(r.url), r.similarity || '', 'Near Duplicate (90%+ similar)', 'Differentiate content OR add canonical pointing to the primary version', 'TODO']);\n});\nconst t14 = makeTab('Indexation Issues', ['#', 'URL', 'Value', 'Issue Type', 'Fix Action', 'Status'], idxRows);\nif (t14) tabs.push(t14);\n\n// ================================================================\n// TAB 15: EXACT DUPLICATES\n// ================================================================\nconst dupRows = (audit.content?.exact_duplicates?.rows || []).map((r, i) => [\n  i + 1, clean(r.url), r.hash || '', 'Exact Duplicate HTML',\n  'Add canonical pointing to the preferred URL, OR 301 redirect this URL to the canonical version', 'TODO'\n]);\nconst t15 = makeTab('Exact Duplicates', ['#', 'URL', 'Duplicate Hash', 'Issue', 'Fix Action', 'Status'], dupRows);\nif (t15) tabs.push(t15);\n\n// ================================================================\n// TAB 16: SECURITY\n// ================================================================\nconst secRows = [];\nlet si2 = 1;\n(audit.security?.http_pages?.rows || []).forEach(r => {\n  secRows.push([si2++, clean(r.url), r.status_code || '', 'HTTP (Insecure)', '301 redirect to HTTPS \u2014 verify SSL certificate covers all subdomains', 'TODO']);\n});\n(audit.security?.mixed_content?.rows || []).forEach(r => {\n  secRows.push([si2++, clean(r.url), '', 'Mixed Content', 'Update all HTTP resource URLs (images, scripts, CSS) on this page to HTTPS', 'TODO']);\n});\nconst t16 = makeTab('Security', ['#', 'URL', 'Status Code', 'Issue', 'Fix Action', 'Status'], secRows);\nif (t16) tabs.push(t16);\n\n// ================================================================\n// TAB 17: ROBOTS BLOCKED\n// ================================================================\nconst robotRows = (audit.response_codes?.blocked_robots?.rows || []).map((r, i) => [\n  i + 1, clean(r.url), clean(r.robots_line), 'Blocked by robots.txt',\n  'If this page should be crawled by Google, update robots.txt to allow this URL pattern', 'TODO'\n]);\nconst t17 = makeTab('Robots Blocked', ['#', 'URL', 'Robots.txt Rule', 'Issue', 'Fix Action', 'Status'], robotRows);\nif (t17) tabs.push(t17);\n\n// ================================================================\n// TAB 18: SYSTEM PAGES\n// ================================================================\nconst sp = audit.system_pages || {};\nif (sp.total_found > 0) {\n  const spRows = [];\n  spRows.push(['OVERVIEW', '', '', '']);\n  spRows.push([`${sp.total_found} system-generated pages were found during the crawl`, '', '', '']);\n  spRows.push(['These are CMS auto-generated pages (label archives, paginated search, share widgets, feeds)', '', '', '']);\n  spRows.push(['They cannot have custom H1/title tags and create duplicate content signals across your site', '', '', '']);\n  spRows.push(['They have been EXCLUDED from all other tabs in this file \u2014 counts reflect real content pages only', '', '', '']);\n  spRows.push(['', '', '', '']);\n  spRows.push(['BREAKDOWN BY TYPE', '', '', '']);\n  spRows.push(['Type', 'Count', 'Recommendation', '']);\n  (sp.by_type || []).forEach(bt => {\n    const rec = bt.type === 'Label/Tag Archive' ? 'Add noindex meta tag to all label/tag archive pages'\n              : bt.type === 'Search Results'    ? 'Add noindex meta tag to all /search pages'\n              : bt.type === 'Paginated Pages'   ? 'Add canonical pointing to base page, or noindex on paginated pages'\n              : bt.type === 'Share Widget'      ? 'Block in robots.txt: Disallow: /share-widget'\n              : bt.type === 'Feed / RSS'        ? 'Block in robots.txt: Disallow: /feed/'\n              : bt.type === 'Category Archive'  ? 'Add noindex to category archives or ensure they have unique content'\n              : bt.type === 'Tag Archive'       ? 'Add noindex to tag archives \u2014 they rarely rank'\n              : 'Review and add noindex or robots.txt Disallow';\n    spRows.push([bt.type, bt.count, rec, '']);\n  });\n  spRows.push(['', '', '', '']);\n  spRows.push(['WHY THIS MATTERS', '', '', '']);\n  spRows.push(['Crawl Budget', 'Google wastes crawl budget on these pages instead of your real content pages', '', '']);\n  spRows.push(['Duplicate Content', 'These pages often have identical or near-identical content \u2014 dilutes SEO signals', '', '']);\n  spRows.push(['False Metrics', 'They inflate issue counts in SEO tools \u2014 excluded here for accurate reporting', '', '']);\n  spRows.push(['H1/Title Issues', 'System pages often have missing H1s/titles that cannot be fixed \u2014 excluded from those counts', '', '']);\n\n  tabs.push({\n    name: 'System Pages',\n    headers: ['Detail', 'Value', 'Action', 'Notes'],\n    rows: spRows,\n  });\n}\n\n// ================================================================\n// FINAL OUTPUT\n// ================================================================\nreturn [{\n  json: {\n    spreadsheet_title: `SEO Implementation Sheet \u2014 ${domain} \u2014 ${date}`,\n    domain,\n    date,\n    total_tabs: tabs.length,\n    tabs,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "90567f5b-2fb3-4301-9770-b201a179e1e1",
      "name": "Upload file",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        3568,
        4496
      ],
      "parameters": {
        "name": "={{ $json.spreadsheet_title }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": "1d1CBQ2dcOMftF-OEMCHFg-TzWGwr8fjl",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1d1CBQ2dcOMftF-OEMCHFg-TzWGwr8fjl",
          "cachedResultName": "SEO Audits"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "b57a8ed6-17c2-416f-891c-7978b9276374",
      "name": "Data",
      "type": "n8n-nodes-base.set",
      "position": [
        2912,
        4256
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "25042583-ced8-468e-972a-b0ded3b3f7f3",
              "name": "html",
              "type": "string",
              "value": "={{ $json.html }}"
            },
            {
              "id": "c2843684-1b2f-4394-b00b-fc119bc446cb",
              "name": "Title",
              "type": "string",
              "value": "={{ $json.domain }} -- {{ $('PSI Parser').item.json.meta.crawl_date }}"
            },
            {
              "id": "2f68a1a1-ef86-40ae-ae1f-2954eccca3ff",
              "name": "website",
              "type": "string",
              "value": "={{ $json.website }}"
            },
            {
              "id": "75fa7484-f809-4157-9e4c-efb2a2ba3a13",
              "name": "message_id",
              "type": "string",
              "value": "={{ $('Arrange Data').item.json.message_id }}"
            },
            {
              "id": "ca94a158-7958-417d-a387-8b1207b69559",
              "name": "channel_id",
              "type": "string",
              "value": "={{ $('Arrange Data').item.json.channel_id }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "feb899b8-d1d7-4a6e-ac26-ca913de3ca81",
      "name": "Excel File Builder",
      "type": "n8n-nodes-base.code",
      "position": [
        3184,
        4496
      ],
      "parameters": {
        "jsCode": "// ================================================================\n// EXCEL FILE BUILDER \u2014 Production v2.0\n// Node name in n8n: \"Excel File Builder\"\n// Input:  Tab Builder output (sheetData with tabs array)\n// Output: ONE .xls file (Excel XML Spreadsheet format)\n//\n// FEATURES:\n//   - Dark navy header row with white bold text\n//   - CRITICAL rows highlighted red in Summary tab\n//   - INFO rows highlighted light grey in Summary tab\n//   - Bot-blocked note rows styled differently\n//   - Clean divider rows styled blue in Summary tab\n//   - All cells forced as String (prevents formula injection)\n//   - Compatible with Excel, LibreOffice, Google Sheets\n// ================================================================\n\nconst sheetData = $input.first().json;\nif (!sheetData || !sheetData.tabs || sheetData.tabs.length === 0) {\n  throw new Error('No tab data received. Check Tab Builder node is connected and producing output.');\n}\n\n// \u2500\u2500 COLOUR CONSTANTS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst COLOR = {\n  HEADER_BG:    '#0A1628',  // Dark navy \u2014 header row\n  HEADER_FG:    '#FFFFFF',  // White text\n  DIVIDER_BG:   '#2563EB',  // Blue \u2014 section dividers in summary\n  DIVIDER_FG:   '#FFFFFF',  // White text\n  CRITICAL_BG:  '#FEE2E2',  // Light red \u2014 CRITICAL status rows\n  CRITICAL_FG:  '#B91C1C',  // Dark red text\n  WARNING_BG:   '#FFFBEB',  // Light amber \u2014 Check/Fix Soon rows\n  WARNING_FG:   '#92400E',  // Amber text\n  OK_BG:        '#ECFDF5',  // Light green \u2014 OK rows\n  OK_FG:        '#065F46',  // Green text\n  INFO_BG:      '#F8FAFC',  // Light grey \u2014 info-only rows\n  INFO_FG:      '#475569',  // Grey text\n  BOT_BG:       '#F1F5F9',  // Very light grey \u2014 bot-blocked rows\n  BOT_FG:       '#64748B',  // Medium grey text\n  ODD_ROW_BG:   '#FAFAFA',  // Alternate row shading (odd)\n  DEFAULT_BG:   '#FFFFFF',  // Default white\n};\n\n// \u2500\u2500 BUILD EXCEL XML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet xml = `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?mso-application progid=\"Excel.Sheet\"?>\n<Workbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"\n xmlns:o=\"urn:schemas-microsoft-com:office:office\"\n xmlns:x=\"urn:schemas-microsoft-com:office:excel\"\n xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\"\n xmlns:html=\"http://www.w3.org/TR/REC-html40\">\n  <Styles>\n    <!-- Default cell style -->\n    <Style ss:ID=\"Default\" ss:Name=\"Normal\">\n      <Alignment ss:Vertical=\"Bottom\" ss:WrapText=\"0\"/>\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"#000000\"/>\n      <Interior/>\n    </Style>\n    <!-- Header row: dark navy background, white bold -->\n    <Style ss:ID=\"Header\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.HEADER_FG}\" ss:Bold=\"1\"/>\n      <Interior ss:Color=\"${COLOR.HEADER_BG}\" ss:Pattern=\"Solid\"/>\n      <Alignment ss:Vertical=\"Center\"/>\n    </Style>\n    <!-- Section divider: blue background, white bold (Summary tab) -->\n    <Style ss:ID=\"Divider\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.DIVIDER_FG}\" ss:Bold=\"1\"/>\n      <Interior ss:Color=\"${COLOR.DIVIDER_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- CRITICAL status rows: light red -->\n    <Style ss:ID=\"Critical\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.CRITICAL_FG}\" ss:Bold=\"1\"/>\n      <Interior ss:Color=\"${COLOR.CRITICAL_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- Warning / Fix Soon rows: light amber -->\n    <Style ss:ID=\"Warning\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.WARNING_FG}\"/>\n      <Interior ss:Color=\"${COLOR.WARNING_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- OK rows: light green -->\n    <Style ss:ID=\"OK\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.OK_FG}\"/>\n      <Interior ss:Color=\"${COLOR.OK_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- Info-only rows: light grey -->\n    <Style ss:ID=\"Info\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.INFO_FG}\"/>\n      <Interior ss:Color=\"${COLOR.INFO_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- Bot-blocked rows: very light grey -->\n    <Style ss:ID=\"BotBlocked\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"${COLOR.BOT_FG}\" ss:Italic=\"1\"/>\n      <Interior ss:Color=\"${COLOR.BOT_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- Alternating row (odd rows slightly shaded) -->\n    <Style ss:ID=\"OddRow\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"#000000\"/>\n      <Interior ss:Color=\"${COLOR.ODD_ROW_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- Plain white row -->\n    <Style ss:ID=\"EvenRow\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"#000000\"/>\n      <Interior ss:Color=\"${COLOR.DEFAULT_BG}\" ss:Pattern=\"Solid\"/>\n    </Style>\n    <!-- TODO cell: neutral -->\n    <Style ss:ID=\"TodoCell\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"#475569\"/>\n      <Interior ss:Color=\"#F1F5F9\" ss:Pattern=\"Solid\"/>\n      <Alignment ss:Horizontal=\"Center\"/>\n    </Style>\n    <!-- Number cells: right-aligned -->\n    <Style ss:ID=\"NumCell\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"11\" ss:Color=\"#000000\"/>\n      <Alignment ss:Horizontal=\"Right\"/>\n    </Style>\n    <!-- INTERNAL badge: red text -->\n    <Style ss:ID=\"InternalBadge\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"10\" ss:Color=\"#B91C1C\" ss:Bold=\"1\"/>\n      <Interior ss:Color=\"#FEE2E2\" ss:Pattern=\"Solid\"/>\n      <Alignment ss:Horizontal=\"Center\"/>\n    </Style>\n    <!-- EXTERNAL badge: amber text -->\n    <Style ss:ID=\"ExternalBadge\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"10\" ss:Color=\"#92400E\" ss:Bold=\"1\"/>\n      <Interior ss:Color=\"#FEF3C7\" ss:Pattern=\"Solid\"/>\n      <Alignment ss:Horizontal=\"Center\"/>\n    </Style>\n    <!-- NO ACTION badge: grey -->\n    <Style ss:ID=\"NoAction\">\n      <Font ss:FontName=\"Calibri\" ss:Size=\"10\" ss:Color=\"#475569\" ss:Italic=\"1\"/>\n      <Interior ss:Color=\"#F1F5F9\" ss:Pattern=\"Solid\"/>\n      <Alignment ss:Horizontal=\"Center\"/>\n    </Style>\n  </Styles>\n`;\n\n// \u2500\u2500 HELPER: Escape XML special characters \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction esc(v) {\n  return String(v ?? '')\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&apos;')\n    .replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, ''); // strip non-printable chars\n}\n\n// \u2500\u2500 HELPER: Determine cell style \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfunction getCellStyle(tabName, colIndex, cellValue, rowIndex, allCells) {\n  const isSummary = tabName === 'Summary';\n  const cellStr   = String(cellValue ?? '');\n\n  // \u2500\u2500 Summary tab special styling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  if (isSummary) {\n    // Section divider rows (first column starts with \u2500\u2500)\n    if (cellStr.startsWith('\u2500\u2500')) return 'Divider';\n\n    // Status column (col 2, index 2) in summary data rows\n    if (colIndex === 2 && rowIndex > 0) {\n      if (cellStr === 'CRITICAL')        return 'Critical';\n      if (cellStr === 'Fix Soon')        return 'Warning';\n      if (cellStr === 'Check Manually') return 'Warning';\n      if (cellStr === 'OK')              return 'OK';\n      if (cellStr === 'No Action')       return 'Info';\n      if (cellStr === 'Reference')       return 'Info';\n    }\n\n    // Count column (col 1, index 1) \u2014 colour based on status in col 2\n    if (colIndex === 1 && rowIndex > 0) {\n      const statusCell = String(allCells[2] ?? '');\n      if (statusCell === 'CRITICAL')        return 'Critical';\n      if (statusCell === 'Fix Soon')        return 'Warning';\n      if (statusCell === 'Check Manually') return 'Warning';\n      if (statusCell === 'OK')              return 'OK';\n      if (statusCell === 'No Action')       return 'Info';\n      if (statusCell === 'Reference')       return 'Info';\n    }\n    return null; // default\n  }\n\n  // \u2500\u2500 Issue tabs \u2014 Type/badge column \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  if (tabName === 'Broken Pages (404)' && colIndex === 1) {\n    if (cellStr === 'INTERNAL') return 'InternalBadge';\n    if (cellStr === 'EXTERNAL') return 'ExternalBadge';\n  }\n\n  // \u2500\u2500 Status column (last column of most tabs) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  const lastCol = (allCells.length - 1);\n  if (colIndex === lastCol) {\n    if (cellStr === 'TODO')                    return 'TodoCell';\n    if (cellStr === 'No action needed \u2014 this platform blocks all automated crawlers. Link works fine in a browser.') return 'NoAction';\n  }\n\n  // \u2500\u2500 Bot-blocked tab \u2014 entire row in grey \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  if (tabName === 'Bot-Blocked 403 (Info)') return 'BotBlocked';\n\n  // \u2500\u2500 Alternating row shading for data tabs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  if (rowIndex > 0) {\n    return rowIndex % 2 === 0 ? 'EvenRow' : 'OddRow';\n  }\n\n  return null;\n}\n\n// \u2500\u2500 BUILD EACH WORKSHEET \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nfor (const tab of sheetData.tabs) {\n  if (!tab.rows || tab.rows.length === 0) continue;\n\n  // Clean tab name: max 31 chars, no forbidden characters\n  const cleanName = tab.name\n    .replace(/[[\\]*\\/:\\\\?]/g, '')\n    .substring(0, 31)\n    .trim();\n\n  // For Summary: rows already include headers in the row data\n  // For others: first row = headers, then data rows\n  const isSummary = tab.name === 'Summary';\n  const allRowData = isSummary\n    ? tab.rows\n    : [tab.headers, ...tab.rows];\n\n  xml += `\\n  <Worksheet ss:Name=\"${esc(cleanName)}\">`;\n  xml += `\\n    <Table ss:DefaultColumnWidth=\"120\">`;\n\n  for (let rIdx = 0; rIdx < allRowData.length; rIdx++) {\n    const row = allRowData[rIdx];\n    if (!Array.isArray(row)) continue;\n\n    // Determine if this is the header row (first row of non-summary tabs)\n    const isHeaderRow = !isSummary && rIdx === 0;\n\n    xml += `\\n      <Row${isHeaderRow ? ' ss:Height=\"22\"' : ''}>`;\n\n    for (let cIdx = 0; cIdx < row.length; cIdx++) {\n      const cellVal = row[cIdx];\n      const cellStr = String(cellVal ?? '');\n\n      // Style decision\n      let styleID = null;\n      if (isHeaderRow) {\n        styleID = 'Header';\n      } else {\n        styleID = getCellStyle(tab.name, cIdx, cellStr, rIdx, row);\n      }\n\n      const styleAttr = styleID ? ` ss:StyleID=\"${styleID}\"` : '';\n\n      // Force all cells as String to prevent Excel misinterpreting URLs, numbers, dates\n      xml += `\\n        <Cell${styleAttr}><Data ss:Type=\"String\">${esc(cellStr)}</Data></Cell>`;\n    }\n\n    xml += `\\n      </Row>`;\n  }\n\n  xml += `\\n    </Table>`;\n  xml += `\\n  </Worksheet>`;\n}\n\nxml += `\\n</Workbook>`;\n\n// \u2500\u2500 PRODUCE OUTPUT FILE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst cleanDomain = (sheetData.domain || 'report')\n  .replace(/[^a-zA-Z0-9_\\-]/g, '_')\n  .substring(0, 40);\nconst filename = `${cleanDomain}_SEO_Issues_${sheetData.date || 'audit'}.xls`;\n\nconst buffer     = Buffer.from(xml, 'utf-8');\nconst binaryData = await this.helpers.prepareBinaryData(buffer, filename, 'application/vnd.ms-excel');\n\nreturn [{\n  json: {\n    status:               'Success',\n    filename:             filename,\n    total_tabs_generated: sheetData.tabs.filter(t => t.rows?.length > 0).length,\n    domain:               sheetData.domain,\n    date:                 sheetData.date,\n    spreadsheet_title:    sheetData.spreadsheet_title,\n  },\n  binary: {\n    data: binaryData,\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f4221953-6510-4531-aaf3-bd893f5b90c6",
      "name": "HTML TO PDF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3120,
        4256
      ],
      "parameters": {
        "url": "https://api.pdfendpoint.com/v1/convert",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "html",
              "value": "={{ $json.html }}"
            },
            {
              "name": "use_print_media ",
              "value": "true"
            },
            {
              "name": "filename",
              "value": "={{ $json.Title }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "93a0017c-e728-416c-91d9-66f7e4d204ba",
      "name": "Upload to Drive",
      "type": "n8n-nodes-base.googleDrive",
      "position": [
        3568,
        4256
      ],
      "parameters": {
        "name": "={{ $json.data.filename }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "My Drive"
        },
        "options": {},
        "folderId": {
          "__rl": true,
          "mode": "list",
          "value": "1d1CBQ2dcOMftF-OEMCHFg-TzWGwr8fjl",
          "cachedResultUrl": "https://drive.google.com/drive/folders/1d1CBQ2dcOMftF-OEMCHFg-TzWGwr8fjl",
          "cachedResultName": "SEO Audits"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "59dd3aeb-b6c2-4d15-aae0-0aa014818365",
      "name": "Downlaod PDF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3344,
        4256
      ],
      "parameters": {
        "url": "={{ $json.data.url }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "ac6974bc-ce05-4029-884c-94b2c098597c",
      "name": "Slack Trigger",
      "type": "n8n-nodes-base.slackTrigger",
      "position": [
        -704,
        4560
      ],
      "parameters": {
        "options": {},
        "trigger": [
          "message"
        ],
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "D0ATVN7D5B4"
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "1127850e-2a57-4b3e-9dda-003223c95ad9",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        -480,
        4560
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "425fa504-b19a-4ce2-ba72-ed56f2bfde3d",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "={{ $json.text }}",
              "rightValue": "#Audit"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "f9e55f1d-238a-4871-9a14-d88856d6faa5",
      "name": "Append row in sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4240,
        4256
      ],
      "parameters": {
        "columns": {
          "value": {
            "Website": "={{ $('Arrange Data').first().json.website }}",
            "Excel Link": "={{ $json.webViewLink[0] }}",
            "Report Link": "={{ $json.webViewLink[1] }}"
          },
          "schema": [
            {
              "id": "Website",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Website",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Report Link",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Report Link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Excel Link",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Excel Link",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1kNbCGSczXC5RXFx3QuP7Hr_m4ltfJvYNC-3vhiF1j9k/edit#gid=0",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "1kNbCGSczXC5RXFx3QuP7Hr_m4ltfJvYNC-3vhiF1j9k",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1kNbCGSczXC5RXFx3QuP7Hr_m4ltfJvYNC-3vhiF1j9k/edit?usp=drivesdk",
          "cachedResultName": "SF Audit Logs"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e1941f7c-7258-4ef6-803a-0eb2debc1ca6",
      "name": "Aggregate",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4000,
        4368
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "webViewLink"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "68d503f6-a2b8-4eaf-bb7a-b1d94b72a50e",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        3792,
        4368
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "2cc18049-3178-42ae-bcb0-42604da11eb4",
      "name": "Arrange Data",
      "type": "n8n-nodes-base.set",
      "position": [
        1344,
        4368
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "f53cb147-793b-4e9d-b6ca-072fc9b9cea0",
              "name": "website",
              "type": "string",
              "value": "={{ $json.url }}"
            },
            {
              "id": "f719866f-ca5e-4363-bbdf-06c0ce6d0013",
              "name": "task_id",
              "type": "string",
              "value": "={{ $json.task_id }}"
            },
            {
              "id": "9d86383d-5882-40bb-b1f7-eef15be3d438",
              "name": "message_id",
              "type": "string",
              "value": "={{ $('Input Cleaner').first().json.message_id }}"
            },
            {
              "id": "5aba2041-b458-4716-8db2-57c05e8e624e",
              "name": "channel_id",
              "type": "string",
              "value": "={{ $('Input Cleaner').first().json.channel_id }}"
            }
          ]
        }
      },
      "executeOnce": true,
      "typeVersion": 3.4
    },
    {
      "id": "8191d939-93bb-4902-b517-12a427c843c1",
      "name": "Send Failed Message",
      "type": "n8n-nodes-base.slack",
      "position": [
        1120,
        4464
      ],
      "parameters": {
        "text": "API is Down or the SF is Carshed Try again",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Input Cleaner').item.json.channel_id }}"
        },
        "otherOptions": {
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $('Input Cleaner').item.json.message_id }}"
            }
          },
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.4
    },
    {
      "id": "d7ad489d-462c-482a-b5ea-dba000dd2325",
      "name": "Send Report",
      "type": "n8n-nodes-base.slack",
      "position": [
        4240,
        4448
      ],
      "parameters": {
        "text": "=PDF Report:  {{ $json.webViewLink[1] }}\n\nFixes CSV: {{ $json.webViewLink[0] }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Data').item.json.channel_id }}"
        },
        "otherOptions": {
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $('Data').item.json.message_id }}"
            }
          },
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.4
    },
    {
      "id": "f102f393-630a-4a51-9344-2fe8be61540b",
      "name": "Extract URL",
      "type": "n8n-nodes-base.code",
      "position": [
        -256,
        4560
      ],
      "parameters": {
        "jsCode": "// Get the raw text from the previous node \n// (Make sure to map the correct input variable here if it's not called \"text\")\nconst incomingText = $input.item.json.text || \"\";\n\nlet cleanUrl = null;\n\n// Regex to find an HTTP or HTTPS link, ignoring the < and > brackets Slack adds\nconst urlRegex = /https?:\\/\\/[^\\s>]+/g;\n\n// Search the text for the URL\nconst match = incomingText.match(urlRegex);\n\n// If a URL was found in the text\nif (match && match.length > 0) {\n  cleanUrl = match[0];\n  \n  // Return the item with a new \"extracted_url\" field\n  return { \n    json: { \n      ...$input.item.json, // Keep the original data just in case\n      extracted_url: cleanUrl,\n      isValid: true\n    } \n  };\n} else {\n  // If no URL was found, return false so you can filter it out\n  return { \n    json: { \n       ...$input.item.json,\n      extracted_url: null,\n      isValid: false\n    } \n  };\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "df0fd124-3262-464c-8661-1cec9a3977a3",
      "name": "Report Builder",
      "type": "n8n-nodes-base.code",
      "position": [
        2720,
        4256
      ],
      "parameters": {
        "jsCode": "// ================================================================\n// SEO AUDIT REPORT BUILDER \u2014 Production v4.2\n// Node name in n8n: \"Report Builder\"\n// Input: merged audit+psi JSON from PSI Parser node\n//\n// FIXES IN v4.2:\n//  - real4xxCount = broken404Count (404-only unique URLs, not all 4xx)\n//  - Bot-blocked: count-only note, no table\n//  - New: manual_check_403 table for unknown external 403s\n//  - External redirect: shows true count (203) from crawl_overview\n//  - Internal redirect: shows true count from crawl_overview\n// ================================================================\n\nconst audit = $input.first().json;\nconst psi   = audit.psi || {};\nconst co    = audit.meta.crawl_overview || {};\n\n// \u2500\u2500 CRAWL OVERVIEW SHORTCUTS \u2500\u2500\nconst trueTotalCrawled = co.total_urls_crawled || audit.meta.total_urls_crawled || 0;\nconst trueIndexable    = co.internal?.indexable || audit.meta.indexable || 0;\n\n// \u2500\u2500 WATERMARK \u2500\u2500\nconst wm = $('Input Cleaner').first().json.watermark;\n\n// \u2500\u2500 BASE HELPERS \u2500\u2500\nconst safe = v => String(v||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\nconst num  = v => { const n = parseInt(v||0); return isNaN(n)?'0':n.toLocaleString(); };\nconst pct  = (v,t) => t>0?Math.round((v/t)*100):0;\nconst domain = (audit.meta.website||'').replace(/https?:\\/\\//,'').replace(/\\/$/,'');\n\n// \u2500\u2500 AGENCY CONFIG \u2500\u2500 (change these to your agency details)\nconst AGENCY = {\n  name:    'AuditCore',\n  initial: 'AC',\n  email:   '+1234567890',\n  website: '',\n  tagline: 'Technical SEO & Growth'\n};\n\n// \u2500\u2500 HEALTH SCORE COLORS \u2500\u2500\nconst sc = audit.health.score;\nconst scoreColor = sc>=80?'#10B981':sc>=60?'#F59E0B':sc>=40?'#EF4444':'#B91C1C';\n\n// \u2500\u2500 CWV HELPERS \u2500\u2500\nconst cwvColor  = s => s==='good'?'#10B981':s==='needs_improvement'?'#F59E0B':'#EF4444';\nconst cwvBg     = s => s==='good'?'#ECFDF5':s==='needs_improvement'?'#FFFBEB':'#FEF2F2';\nconst cwvLabel  = s => s==='good'?'Good':s==='needs_improvement'?'Needs Improvement':'Poor';\nconst cwvBorder = s => s==='good'?'#A7F3D0':s==='needs_improvement'?'#FDE68A':'#FECACA';\n\n// \u2500\u2500 SCREAMING FROG SPEED \u2500\u2500\nconst avgRT      = parseFloat(audit.meta.avg_response_time||0);\nconst avgRTLabel = avgRT<0.2?'Excellent':avgRT<0.5?'Good':avgRT<1.0?'Needs Attention':avgRT<2.0?'Slow':'Critical';\nconst avgRTColor = avgRT<0.5?'#10B981':avgRT<1.0?'#F59E0B':'#EF4444';\n\n// \u2500\u2500 4xx COUNTS \u2500\u2500\n// real4xxCount = unique 404/410/400 URLs only (what shows in headings + priority action + cover)\nconst real4xxCount = audit.response_codes?.errors_4xx?.real_count ?? audit.response_codes?.errors_4xx?.broken_404 ?? audit.broken_link_sources?.length ?? 0;\n\n// \u2500\u2500 PERF SCORE \u2500\u2500\nconst perfScore = psi.performance_score || 0;\nconst perfColor = perfScore>=90?'#10B981':perfScore>=50?'#F59E0B':'#EF4444';\nconst perfLabel = perfScore>=90?'Good':perfScore>=50?'Needs Improvement':'Poor';\n\n// \u2500\u2500 TOP 3 PRIORITY ACTIONS \u2500\u2500\nconst SEV = { critical:1000, warning:10, low:1 };\nconst psiLcpPoor = psi.lcp?.status === 'poor';\nconst priorityCandidates = [\n  { w: SEV.critical+(real4xxCount*10),                               t:`Fix ${num(real4xxCount)} broken pages (404 errors) \u2014 each wastes crawl budget and destroys link equity` },\n  { w: SEV.critical+(audit.response_codes?.errors_5xx?.count||0)*10, t:`Resolve ${num(audit.response_codes?.errors_5xx?.count)} server errors (5xx) \u2014 Google may stop crawling affected pages` },\n  { w: SEV.critical+(audit.headings?.h1_missing?.count||0)*8,        t:`Add H1 headings to ${num(audit.headings?.h1_missing?.count)} pages \u2014 missing the strongest on-page ranking signal` },\n  { w: SEV.critical+(audit.page_titles?.missing?.count||0)*8,        t:`Add missing page titles to ${num(audit.page_titles?.missing?.count)} pages \u2014 zero title-based ranking signal` },\n  { w: SEV.critical+(audit.security?.http_pages?.count||0)*8,        t:`Fix ${num(audit.security?.http_pages?.count)} HTTP insecure pages \u2014 ranking penalty and browser warning` },\n  { w: SEV.critical+(audit.content?.exact_duplicates?.count||0)*8,   t:`Fix ${num(audit.content?.exact_duplicates?.count)} exact duplicate pages \u2014 splits PageRank, confuses Google` },\n  { w: psiLcpPoor?(SEV.critical+500):0,                              t:`Fix LCP of ${psi.lcp?.display} (Poor) \u2014 direct Google ranking factor for mobile search` },\n  { w: SEV.warning+(audit.meta_descriptions?.missing?.count||0),     t:`Write meta descriptions for ${num(audit.meta_descriptions?.missing?.count)} pages to recover lost click-through rate` },\n  { w: SEV.warning+(audit.headings?.h1_multiple?.count||0)*5,        t:`Fix multiple H1 tags on ${num(audit.headings?.h1_multiple?.count)} pages \u2014 likely a single template code fix` },\n  { w: SEV.warning+(audit.images?.missing_alt?.count||0),            t:`Add alt text to ${num(audit.images?.missing_alt?.count)} images \u2014 invisible to Google, fails accessibility standards` },\n  { w: SEV.warning+(psi.opportunities?.unused_javascript?.savings_kb||0)*3, t:`Remove ${psi.opportunities?.unused_javascript?.savings_kb}KB unused JavaScript \u2014 improves LCP by ~${psi.opportunities?.unused_javascript?.savings_lcp_ms||0}ms` },\n  { w: SEV.low+(audit.redirect_reports?.chains?.count||0),           t:`Resolve ${num(audit.redirect_reports?.chains?.count)} redirect chains \u2014 losing link equity at every extra hop` },\n].filter(p=>p.w>SEV.low).sort((a,b)=>b.w-a.w).slice(0,3);\nwhile(priorityCandidates.length<3) priorityCandidates.push({t:'No additional critical issues found',w:0});\n\n// \u2500\u2500 RESPONSE CODE CARD DATA (from crawl_overview \u2014 authoritative SF numbers) \u2500\u2500\nconst rcVar   = co.response_codes || {};\nconst coTotal = co.total_urls_crawled || audit.meta.total_urls_crawled || 1;\n\nconst responseCards = [\n  { label:'2xx OK',        total:(rcVar.internal_2xx||0)+(rcVar.external_2xx||0)||rcVar.success_2xx||0,  internal:rcVar.internal_2xx||0, external:rcVar.external_2xx||0, col:'#10B981',bg:'#ECFDF5',bdr:'#A7F3D0', sub:'Successfully loaded' },\n  { label:'3xx Redirects', total:(rcVar.internal_3xx||0)+(rcVar.external_3xx||0)||rcVar.redirect_3xx||0, internal:rcVar.internal_3xx||0, external:rcVar.external_3xx||0, col:'#F59E0B',bg:'#FFFBEB',bdr:'#FDE68A', sub:'URLs that redirect' },\n  { label:'4xx Errors',    total:(rcVar.internal_4xx||0)+(rcVar.external_4xx||0)||rcVar.error_4xx||0,    internal:rcVar.internal_4xx||0, external:rcVar.external_4xx||0, col:'#EF4444',bg:'#FEF2F2',bdr:'#FECACA', sub:'Client errors (404/403)' },\n  { label:'5xx Errors',    total:rcVar.internal_5xx||0,                                                  internal:rcVar.internal_5xx||0, external:rcVar.external_5xx||0, col:'#B91C1C',bg:'#FEF2F2',bdr:'#FECACA', sub:'Server errors \u2014 urgent' },\n];\n\n// \u2500\u2500 GLOSSARY DATA \u2500\u2500\nconst glossaryItems = [\n  ['SF','Screaming Frog','A website crawler tool that audits sites by following links and extracting SEO data from every page it finds. Source of all crawl data in this report.'],\n  ['PSI','PageSpeed Insights','Google\\'s free performance testing tool. Uses Lighthouse and real-user CrUX data. Scores run 0\u2013100.'],\n  ['CWV','Core Web Vitals','Three Google ranking signals measuring real-world page experience: LCP, CLS, and INP (or TBT in lab data).'],\n  ['LCP','Largest Contentful Paint','Time for the largest visible element (usually hero image) to load. Good = under 2.5s. Direct ranking factor.'],\n  ['CLS','Cumulative Layout Shift','Measures visual stability \u2014 how much elements shift during load. Good = under 0.1.'],\n  ['TBT','Total Blocking Time','Lab proxy for INP. How long JavaScript blocks the main thread. Good = under 200ms.'],\n  ['FCP','First Contentful Paint','Time until first text or image renders. Good = under 1.8s.'],\n  ['TTFB','Time to First Byte','Time from request to first byte from server. Reflects server speed. Good = under 800ms.'],\n  ['SI','Speed Index','How quickly page content visually fills in during load. Good = under 3.4s.'],\n  ['TTI','Time to Interactive','When the page becomes fully responsive to user input. Good = under 3.8s.'],\n  ['INP','Interaction to Next Paint','Core Web Vital measuring responsiveness to clicks/taps. Replaced FID in March 2024.'],\n  ['H1 / H2','Heading Tags','HTML heading levels. H1 = main page topic (one per page). H2 = section headings. Both are ranking signals.'],\n  ['404','Not Found Error','Page does not exist. Internal 404s waste Google crawl budget and destroy link equity.'],\n  ['5xx','Server Error Codes','500\u2013599 responses \u2014 server failed to process the request. Google may stop crawling affected pages.'],\n  ['3xx','Redirect Codes','301 = Permanent (passes ~95% link equity). 302 = Temporary. Chains waste crawl budget.'],\n  ['4xx','Client Error Codes','400\u2013499 responses including 404 (Not Found), 403 (Forbidden), 410 (Gone).'],\n  ['CDN','Content Delivery Network','Servers that serve content from locations closest to each user \u2014 reduces latency, improves LCP.'],\n  ['HTTPS','HyperText Transfer Protocol Secure','Encrypted HTTP. Google uses HTTPS as a ranking signal. Chrome shows \"Not Secure\" for HTTP.'],\n  ['noindex','No-Index Directive','Tag telling Google not to show a page in search results. Catastrophic if applied to content pages accidentally.'],\n  ['canonical','Canonical Tag','Tells Google which URL is the preferred version when duplicate content exists across multiple URLs.'],\n  ['robots.txt','Robots Exclusion Standard','File at domain.com/robots.txt telling crawlers which pages to skip. Does not prevent indexing by itself.'],\n  ['CrUX','Chrome User Experience Report','Real-user performance data from Chrome. Used in PSI field data. Only available for sites with enough traffic.'],\n];\n\n// ================================================================\n// COMPONENT HELPERS\n// ================================================================\n\nconst sectionHeader = (icon,title,badgeCount,badgeColor) => `\n  <div style=\"display:flex;align-items:center;gap:12px;margin-bottom:22px;page-break-inside:avoid;break-inside:avoid;\">\n    <div style=\"width:36px;height:36px;border-radius:10px;background:${badgeColor}22;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;\">${icon}</div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:19px;font-weight:700;color:#0A1628;letter-spacing:-0.4px;\">${title}</div>\n    ${badgeCount!==null?`<span style=\"margin-left:auto;font-size:11px;font-weight:600;padding:3px 12px;border-radius:99px;background:${badgeColor}22;color:${badgeColor};border:1px solid ${badgeColor}44;\">${num(badgeCount)} issues</span>`:''}\n  </div>`;\n\nconst tableHeader = (...cols) => `<thead><tr style=\"background:#0A1628;\">${cols.map((c,i)=>`<th style=\"padding:10px 14px;text-align:left;font-size:10px;font-weight:600;color:rgba(255,255,255,0.7);text-transform:uppercase;letter-spacing:0.7px;${i===0?'border-radius:8px 0 0 0;':''}${i===cols.length-1?'border-radius:0 8px 0 0;':''}\">${c}</th>`).join('')}</tr></thead>`;\n\nconst miniCard = (label,val,sub,bg,col) => `\n  <div style=\"padding:14px 18px;background:${bg};border-radius:10px;\">\n    <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:${col};margin-bottom:4px;\">${label}</div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:800;color:${col};\">${val}</div>\n    ${sub?`<div style=\"font-size:11px;color:${col};opacity:0.7;margin-top:3px;\">${sub}</div>`:''}\n  </div>`;\n\nconst cwvCard = (label,display,status,subtext,threshold1,threshold2) => {\n  const col=cwvColor(status); const bg=cwvBg(status); const bdr=cwvBorder(status); const lbl=cwvLabel(status);\n  return `\n  <div style=\"padding:18px 20px;background:${bg};border-radius:12px;border:1.5px solid ${bdr};text-align:center;\">\n    <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:${col};margin-bottom:10px;\">${label}</div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:30px;font-weight:800;color:${col};letter-spacing:-1px;line-height:1;\">${display}</div>\n    <div style=\"margin-top:10px;height:6px;background:rgba(0,0,0,0.1);border-radius:99px;overflow:hidden;position:relative;\">\n      <div style=\"position:absolute;left:0;top:0;height:100%;width:33.3%;background:#10B981;border-radius:99px 0 0 99px;\"></div>\n      <div style=\"position:absolute;left:33.3%;top:0;height:100%;width:33.3%;background:#F59E0B;\"></div>\n      <div style=\"position:absolute;left:66.6%;top:0;height:100%;width:33.4%;background:#EF4444;border-radius:0 99px 99px 0;\"></div>\n    </div>\n    <div style=\"display:flex;justify-content:space-between;margin-top:4px;\">\n      <span style=\"font-size:9px;color:#64748B;\">${threshold1}</span>\n      <span style=\"font-size:9px;color:#64748B;\">${threshold2}</span>\n    </div>\n    <div style=\"margin-top:8px;font-size:11px;font-weight:700;padding:3px 10px;border-radius:99px;background:${col}22;color:${col};display:inline-block;\">${lbl}</div>\n    ${subtext?`<div style=\"font-size:10px;color:#64748B;margin-top:5px;\">${subtext}</div>`:''}\n  </div>`;\n};\n\n// ================================================================\n// ISSUE EXPLANATIONS LIBRARY\n// ================================================================\nconst ISSUES = {\n  errors_4xx:       {icon:'\ud83d\udd34',title:'Broken Pages (4xx Errors)',               sev:'Critical',what:`These pages return \"Not Found\" or a similar error when visited. Every broken link wastes Google's crawl budget, destroys user experience, and bleeds link equity. Cloudflare cdn-cgi paths are excluded \u2014 those are system paths, not real pages.`,fix:`For each broken URL: (1) Restore the page if it should exist, (2) Set up a 301 permanent redirect to the most relevant live alternative, or (3) Remove all internal links pointing to it. The table below shows exactly which pages link to each broken URL.`,impact:'Very High \u2014 directly destroys crawlability, link equity, and user trust'},\n  errors_5xx:       {icon:'\ud83d\udd34',title:'Server Errors (5xx)',                      sev:'Critical',what:`The server is actively failing on these requests \u2014 not missing pages but server crashes. Google encounters these and may stop crawling affected URLs entirely, eventually deindexing them.`,fix:`Check server error logs immediately. Common causes: PHP memory limits, database failures, misconfigured plugins, or server overload. Each 5xx must resolve to a clean 200 response.`,impact:'Very High \u2014 Google may deindex affected pages and reduce site-wide crawl frequency'},\n  h1_missing:       {icon:'\ud83d\udd34',title:'Missing H1 Headings',                     sev:'Critical',what:`The H1 is the primary heading Google uses to understand what a page is about. Pages without an H1 force Google to guess the topic from context \u2014 consistently resulting in lower rankings.`,fix:`Add exactly one H1 to each affected page. Clearly describe the page topic using the primary target keyword naturally. The H1 can differ from the page title \u2014 it should be descriptive and specific.`,impact:'Very High \u2014 one of the top three strongest on-page ranking signals'},\n  lcp_poor:         {icon:'\ud83d\udd34',title:'Poor Largest Contentful Paint (LCP)',      sev:'Critical',what:`LCP measures how long the largest visible element (usually the hero image) takes to load. At ${psi.lcp?.display}, this site is significantly below Google's \"Good\" threshold of 2.5s. LCP is a Core Web Vital and a direct Google ranking factor since 2021.`,fix:`Priority: (1) Add fetchpriority=\"high\" to the LCP image, (2) Preload it using <link rel=\"preload\">, (3) Remove unused JavaScript (${psi.opportunities?.unused_javascript?.savings_kb||0}KB savings possible), (4) Fix render-blocking CSS (${psi.opportunities?.render_blocking?.savings_ms||0}ms savings possible).`,impact:'Very High \u2014 direct Google ranking factor, especially on mobile'},\n  titles_missing:   {icon:'\ud83d\udd34',title:'Missing Page Titles',                      sev:'Critical',what:`No title tag exists on these pages. The page title is one of the single strongest on-page ranking signals. Without it, Google has nothing to display in search results and cannot understand the page topic.`,fix:`Add a unique, descriptive title to every page. Keep it 50\u201360 characters. Place the primary keyword near the beginning.`,impact:'Very High \u2014 directly prevents ranking for any keyword'},\n  http_pages:       {icon:'\ud83d\udd34',title:'HTTP (Insecure) Pages',                   sev:'Critical',what:`Pages served over HTTP instead of HTTPS. Chrome shows \"Not Secure\" warnings, destroying user trust. Google officially uses HTTPS as a ranking signal.`,fix:`Ensure all pages are served over HTTPS. Set up 301 redirects from HTTP to HTTPS. Verify your SSL certificate covers all subdomains.`,impact:'Very High \u2014 security, user trust, and confirmed Google ranking factor'},\n  mixed_content:    {icon:'\ud83d\udd34',title:'Mixed Content (HTTP Resources on HTTPS)',  sev:'Critical',what:`HTTPS pages loading resources over insecure HTTP. Browsers may block these resources from loading, breaking page functionality for visitors.`,fix:`Find all HTTP resource URLs on HTTPS pages. Update every src and href to https://. In WordPress, use Better Search Replace to fix database-wide.`,impact:'Very High \u2014 breaks page functionality, undermines HTTPS, triggers browser warnings'},\n  exact_duplicates: {icon:'\ud83d\udd34',title:'Exact Duplicate Pages',                   sev:'Critical',what:`Multiple URLs returning identical HTML content. This splits ranking signals between multiple versions and wastes crawl budget on pages that add no value.`,fix:`Set canonical tags on all non-canonical versions pointing to the preferred URL. Consider 301 redirects from duplicate URLs to the canonical version.`,impact:'Very High \u2014 splits link equity, ranking unpredictability, wastes crawl budget'},\n  h1_duplicate:     {icon:'\ud83d\udfe1',title:'Duplicate H1 Tags Across Pages',           sev:'Warning', what:`Multiple pages sharing the same H1 signals to Google that the pages cover the same topic. This directly triggers keyword cannibalisation \u2014 your own pages compete against each other in search results.`,fix:`Each page must have a unique H1 reflecting its specific topic. If pages genuinely cover the same topic, consider consolidating them via a canonical or redirect.`,impact:'High \u2014 direct keyword cannibalisation and ranking confusion'},\n  h1_multiple:      {icon:'\ud83d\udfe1',title:'Multiple H1 Tags on Single Pages',         sev:'Warning', what:`Two or more H1 tags on one page dilutes the heading signal. This commonly happens when a template element (footer, sidebar, newsletter) is incorrectly marked as H1 \u2014 affecting every page site-wide from one code issue.`,fix:`Almost always a template-level fix. Find the offending element and change it from H1 to H2 or H3 in your theme. One code edit fixes every affected page simultaneously.`,impact:'Medium \u2014 dilutes primary heading signal, confuses Google about page topic'},\n  h2_missing:       {icon:'\ud83d\udd35',title:'Missing H2 Headings',                     sev:'Low',     what:`H2 headings help Google understand what sections a page contains. Pages without H2s appear as undivided text blocks to search engines, reducing ability to rank for secondary keywords.`,fix:`Add H2 headings to divide content into logical sections. Each H2 should describe that section's topic and can include secondary keywords naturally.`,impact:'Low-Medium \u2014 affects content structure signals and secondary keyword ranking'},\n  titles_duplicate: {icon:'\ud83d\udfe1',title:'Duplicate Page Titles',                   sev:'Warning', what:`Multiple pages sharing identical titles means Google cannot determine which page to rank for a given query \u2014 causing direct cannibalisation between your own pages.`,fix:`Write a unique, specific title for every page targeting a different primary keyword. Configure your SEO plugin templates for category and archive pages.`,impact:'High \u2014 direct cannibalisation risk, reduces ranking authority per page'},\n  titles_too_long:  {icon:'\ud83d\udfe1',title:'Page Titles Over 60 Characters',           sev:'Warning', what:`These titles get truncated in Google search results. Users see an incomplete message and Google gives less weight to words after the truncation point.`,fix:`Trim each title to under 60 characters. Place the primary keyword and brand message within the first 50 characters.`,impact:'Medium \u2014 reduced CTR and keyword weight on truncated words'},\n  meta_missing:     {icon:'\ud83d\udfe1',title:'Missing Meta Descriptions',                sev:'Warning', what:`Pages without meta descriptions leave Google to auto-generate a snippet from random page content \u2014 often confusing or unappealing to click. This directly reduces organic click-through rate.`,fix:`Write a unique 140\u2013155 character meta description for each affected page. Include the primary keyword and a clear call to action.`,impact:'Medium \u2014 lower CTR directly reduces organic traffic even without ranking changes'},\n  meta_too_long:    {icon:'\ud83d\udd35',title:'Meta Descriptions Over 155 Characters',    sev:'Low',     what:`Descriptions that exceed Google's display limit get cut off, hiding the key message or CTA from users in search results.`,fix:`Trim each description to under 155 characters. Ensure the most important content appears within the first 140 characters.`,impact:'Low \u2014 affects presentation and CTR but not ranking directly'},\n  img_no_alt:       {icon:'\ud83d\udfe1',title:'Images Missing Alt Text',                  sev:'Warning', what:`Google cannot see images \u2014 it relies entirely on alt text to understand image content. Images without alt text are invisible to search engines and fail accessibility standards (WCAG).`,fix:`Add descriptive alt text to every image. Describe what the image shows. Include the primary keyword where natural. Use empty alt=\"\" for purely decorative images.`,impact:'Medium \u2014 affects image search rankings, accessibility compliance, and on-page relevance'},\n  img_large:        {icon:'\ud83d\udfe1',title:'Oversized Images (Over 100KB)',             sev:'Warning', what:`Large unoptimised images are the single most common cause of poor LCP scores. Every page with 200KB+ images is likely failing Google's mobile performance assessment.`,fix:`Compress images over 100KB using Squoosh, TinyPNG, or ShortPixel. Convert to WebP format (25\u201335% smaller than JPEG). Aim for under 80KB per image. For WordPress, install ShortPixel or Imagify.`,impact:'High \u2014 directly affects Core Web Vitals LCP score and mobile ranking'},\n  canonical_missing:{icon:'\ud83d\udfe1',title:'Missing Canonical Tags',                   sev:'Warning', what:`Without a canonical tag, Google decides on its own which URL version to index \u2014 often choosing the wrong one when the same content is accessible via multiple URLs.`,fix:`Add a self-referencing canonical to every page. In WordPress, Yoast SEO or RankMath does this automatically \u2014 verify canonical tags are enabled in settings.`,impact:'Medium \u2014 duplicate content risk and ranking unpredictability'},\n  noindex_pages:    {icon:'\ud83d\udfe1',title:'Pages with Noindex Directive',             sev:'Warning', what:`These pages are actively hidden from Google. Correct for admin pages, login pages, and archives. Dangerous if applied to service, product, or blog pages accidentally.`,fix:`Review every URL in this list. Remove noindex from any page that should appear in search results. Focus especially on service pages, products, and high-value content.`,impact:'High \u2014 any wrongly noindexed page will never rank regardless of content quality'},\n  low_content:      {icon:'\ud83d\udd35',title:'Thin Content Pages (Under 300 Words)',     sev:'Low',     what:`Pages with very little text give Google almost nothing to understand or rank. Too many thin pages across a domain can drag down the overall quality assessment for the entire site.`,fix:`Expand content to at least 300 words with genuinely useful information. Alternatively, consolidate with a related page via 301 redirect if expansion is not viable.`,impact:'Medium \u2014 thin content is a known negative quality signal affecting the entire domain'},\n  near_duplicates:  {icon:'\ud83d\udfe1',title:'Near-Duplicate Content Pages',             sev:'Warning', what:`Pages with very similar content (90%+ similarity) dilute ranking authority and confuse Google about which page to rank for a given keyword.`,fix:`Differentiate content to make each page genuinely unique, or consolidate using canonical tags pointing from the less important version to the main one.`,impact:'Medium \u2014 dilutes ranking authority and creates cannibalisation risk'},\n  redirect_chains:  {icon:'\ud83d\udfe1',title:'Redirect Chains (Multi-Hop Redirects)',    sev:'Warning', what:`A redirect chain is when URL A redirects to URL B, which redirects to URL C. Each extra hop adds user latency, wastes crawl budget, and bleeds link equity with every step.`,fix:`Update source links to point directly to the final destination URL. Every internal link should resolve in one step to a live 200 page.`,impact:'Medium \u2014 crawl budget waste, link equity loss, and user latency'},\n  redirects_3xx:    {icon:'\ud83d\udd35',title:'Internal Links Pointing to Redirected URLs',sev:'Low',     what:`Internal links point to URLs that redirect rather than linking directly to the final destination. Each redirect adds an unnecessary HTTP request and a marginal crawl cost.`,fix:`Update internal links to point directly to the final canonical destination URL. A CMS search-and-replace tool resolves most cases quickly.`,impact:'Low-Medium \u2014 minor crawl inefficiency and marginal latency increase'},\n  orphan_pages:     {icon:'\ud83d\udfe1',title:'Orphan Pages (No Internal Links)',          sev:'Warning', what:`Pages with zero internal inlinks pointing to them. Google discovers pages mainly through internal links \u2014 orphan pages are often not crawled or indexed regularly, even if they exist and have good content.`,fix:`Add internal links to these pages from relevant content pages. Consider adding them to navigation, sitemaps, or related content sections.`,impact:'Medium \u2014 orphan pages are poorly crawled, receive no link equity, and may not rank even with good content'},\n};\n\n// ================================================================\n// SECTION BUILDER FUNCTIONS\n// ================================================================\n\nfunction buildSFIssues() {\n  const sorted = [...(audit.issues_overview||[])].filter(i=>i.count>0)\n    .sort((a,b)=>({High:0,Medium:1,Low:2}[a.priority]??3)-({High:0,Medium:1,Low:2}[b.priority]??3));\n  let rows = sorted.map((issue,i)=>{\n    const tc={Issue:['#FEE2E2','#B91C1C'],Warning:['#FEF3C7','#92400E'],Opportunity:['#EAF3DE','#3B6D11']}[issue.type]||['#F1F5F9','#475569'];\n    const pc={High:['#FEE2E2','#B91C1C'],Medium:['#FEF3C7','#92400E'],Low:['#F1F5F9','#475569']}[issue.priority]||['#F1F5F9','#475569'];\n    return `<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\">\n      <td style=\"padding:9px 14px;font-size:12.5px;color:#1E293B;\">${safe(issue.name)}</td>\n      <td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 9px;border-radius:99px;background:${tc[0]};color:${tc[1]};\">${safe(issue.type)}</span></td>\n      <td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:600;padding:2px 8px;border-radius:99px;background:${pc[0]};color:${pc[1]};\">${safe(issue.priority)}</span></td>\n      <td style=\"padding:9px 14px;font-size:13px;font-weight:700;color:#0A1628;\">${num(issue.count)}</td>\n      <td style=\"padding:9px 14px;font-size:12px;color:#64748B;\">${safe(issue.percent)}%</td>\n    </tr>`;\n  }).join('');\n  const noise = audit.security_header_noise;\n  if (noise?.count>0) {\n    rows += `<tr style=\"background:#F8FAFC;border-bottom:0.5px solid #E2E8F0;\">\n      <td style=\"padding:9px 14px;font-size:12px;color:#94A3B8;font-style:italic;\">+ ${noise.issues?.length||0} server security header warnings (HSTS, X-Frame-Options, CSP, etc.)</td>\n      <td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 9px;border-radius:99px;background:#F1F5F9;color:#475569;\">Warning</span></td>\n      <td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:600;padding:2px 8px;border-radius:99px;background:#F1F5F9;color:#475569;\">Low</span></td>\n      <td style=\"padding:9px 14px;font-size:13px;font-weight:700;color:#94A3B8;\">${num(noise.count)}</td>\n      <td style=\"padding:9px 14px;font-size:12px;color:#94A3B8;\">server-level \u2014 not SEO critical</td>\n    </tr>`;\n  }\n  return rows;\n}\n\nfunction buildIssueCards() {\n  const checks = [\n    {key:'errors_4xx',    count:real4xxCount,                                        rows:(audit.broken_link_sources||[]).slice(0,5).map(r=>({url:r.broken_url,code:r.status_code}))},\n    {key:'errors_5xx',    count:audit.response_codes?.errors_5xx?.count||0,          rows:audit.response_codes?.errors_5xx?.rows||[]},\n    {key:'lcp_poor',      count:psi.lcp?.status==='poor'?1:0,                        rows:[]},\n    {key:'http_pages',    count:audit.security?.http_pages?.count||0,                rows:audit.security?.http_pages?.rows||[]},\n    {key:'mixed_content', count:audit.security?.mixed_content?.count||0,             rows:[]},\n    {key:'exact_duplicates',count:audit.content?.exact_duplicates?.count||0,         rows:audit.content?.exact_duplicates?.rows||[]},\n    {key:'titles_missing',count:audit.page_titles?.missing?.count||0,                rows:audit.page_titles?.missing?.rows||[]},\n    {key:'h1_missing',    count:audit.headings?.h1_missing?.count||0,                rows:audit.headings?.h1_missing?.rows||[]},\n    {key:'h1_duplicate',  count:audit.headings?.h1_duplicate?.count||0,              rows:[]},\n    {key:'h1_multiple',   count:audit.headings?.h1_multiple?.count||0,               rows:audit.headings?.h1_multiple?.rows||[]},\n    {key:'titles_duplicate',count:audit.page_titles?.duplicate?.count||0,            rows:[]},\n    {key:'titles_too_long',count:audit.page_titles?.too_long?.count||0,              rows:audit.page_titles?.too_long?.rows||[]},\n    {key:'meta_missing',  count:audit.meta_descriptions?.missing?.count||0,          rows:audit.meta_descriptions?.missing?.rows||[]},\n    {key:'meta_too_long', count:audit.meta_descriptions?.too_long?.count||0,         rows:audit.meta_descriptions?.too_long?.rows||[]},\n    {key:'h2_missing',    count:audit.headings?.h2_missing?.count||0,                rows:audit.headings?.h2_missing?.rows||[]},\n    {key:'img_no_alt',    count:audit.images?.missing_alt?.count||0,                 rows:audit.images?.missing_alt?.rows||[]},\n    {key:'img_large',     count:audit.images?.oversized?.count||0,                   rows:audit.images?.oversized?.rows||[]},\n    {key:'canonical_missing',count:audit.canonicals?.missing?.count||0,              rows:audit.canonicals?.missing?.rows||[]},\n    {key:'noindex_pages', count:audit.directives?.noindex?.count||0,                 rows:audit.directives?.noindex?.rows||[]},\n    {key:'low_content',   count:audit.content?.thin_content_300?.count||audit.content?.low_content?.count||0, rows:audit.content?.low_content?.rows||[]},\n    {key:'near_duplicates',count:audit.content?.near_duplicates?.count||0,           rows:[]},\n    {key:'redirect_chains',count:audit.redirect_reports?.chains?.count||0,           rows:audit.redirect_reports?.chains?.rows||[]},\n    {key:'redirects_3xx', count:audit.response_codes?.redirects_3xx?.count||0,       rows:[]},\n    {key:'orphan_pages',  count:audit.orphan_pages?.count||0,                        rows:audit.orphan_pages?.rows||[]},\n  ];\n  const sevOrder={Critical:0,Warning:1,Low:2};\n  const active = checks.filter(c=>c.count>0&&ISSUES[c.key]).sort((a,b)=>sevOrder[ISSUES[a.key].sev]-sevOrder[ISSUES[b.key].sev]);\n  const sevColors={Critical:{border:'#FECACA',hdr:'#FFF5F5',badge:'#FEE2E2',text:'#B91C1C'},Warning:{border:'#FDE68A',hdr:'#FFFBEB',badge:'#FEF3C7',text:'#92400E'},Low:{border:'#BFDBFE',hdr:'#EFF6FF',badge:'#DBEAFE',text:'#1E40AF'}};\n  return active.map(check=>{\n    const issue=ISSUES[check.key]; const c=sevColors[issue.sev];\n    const sample=(check.rows||[]).slice(0,5);\n    let urlRows='';\n    if(sample.length>0){\n      const rowHtml=sample.map((r,i)=>{\n        const url=r.url||r.broken_url||'';\n        const extra=r.title?` \u2014 ${safe(r.title).substring(0,55)}`:r.word_count!==undefined?` (${r.word_count} words)`:r.size_kb?` (${r.size_kb} KB)`:r.code?` \u2192 ${r.code}`:r.hops?` (${r.hops} hops \u2192 ${safe(r.final)})`:r.depth?` (depth: ${r.depth})`:'';\n        return `<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:6px 12px;font-family:monospace;font-size:10.5px;color:#1D4ED8;word-break:break-all;\">${safe(url)}<span style=\"color:#64748B;font-family:'DM Sans',sans-serif;font-size:10px;\">${safe(extra)}</span></td></tr>`;\n      }).join('');\n      urlRows=`<div style=\"padding:0 18px 16px;background:white;\"><table style=\"width:100%;border-collapse:collapse;border-radius:8px;overflow:hidden;border:0.5px solid #E2E8F0;\"><tr style=\"background:#1B2B4B;\"><th style=\"padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:rgba(255,255,255,0.7);text-transform:uppercase;letter-spacing:0.6px;\">Sample URLs (${num(check.count)} total)</th></tr>${rowHtml}</table></div>`;\n    }\n    return `<div style=\"border-radius:12px;overflow:hidden;border:1px solid ${c.border};margin-bottom:16px;\">\n      <div style=\"display:flex;align-items:center;gap:14px;padding:14px 18px;background:${c.hdr};border-bottom:1px solid ${c.border};page-break-inside:avoid;break-inside:avoid;\">\n        <span style=\"font-size:16px;\">${issue.icon}</span>\n        <div>\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:14px;font-weight:700;color:#0A1628;\">${issue.title}</div>\n          <div style=\"font-size:11px;color:#64748B;margin-top:2px;\">${num(check.count)} page${check.count!==1?'s':''} affected</div>\n        </div>\n        <span style=\"margin-left:auto;font-size:10px;font-weight:700;padding:4px 12px;border-radius:99px;background:${c.badge};color:${c.text};text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;\">${issue.sev}</span>\n      </div>\n      <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;background:white;\">\n        <div style=\"padding:16px 18px;border-right:0.5px solid #E2E8F0;\"><div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:8px;\">\ud83d\udccc What it means</div><div style=\"font-size:12.5px;color:#1E293B;line-height:1.65;\">${issue.what}</div></div>\n        <div style=\"padding:16px 18px;border-right:0.5px solid #E2E8F0;\"><div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:8px;\">\ud83d\udd27 How to fix it</div><div style=\"font-size:12.5px;color:#1E293B;line-height:1.65;\">${issue.fix}</div></div>\n        <div style=\"padding:16px 18px;\"><div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:8px;\">\ud83d\udcc8 SEO Impact</div><div style=\"font-size:12.5px;color:#1E293B;line-height:1.65;\">${issue.impact}</div></div>\n      </div>\n      ${urlRows}\n    </div>`;\n  }).join('');\n}\n\nfunction buildSpeedBars() {\n  const buckets=audit.meta.speed_buckets||{};\n  const total=Object.values(buckets).reduce((a,b)=>a+b,0)||1;\n  const bars=[['Excellent (< 0.2s)',buckets.excellent||0,'#10B981'],['Good (0.2\u20130.5s)',buckets.good||0,'#34D399'],['Needs work (0.5\u20131s)',buckets.needs_work||0,'#F59E0B'],['Slow (1\u20132s)',buckets.slow||0,'#EF4444'],['Critical (> 2s)',buckets.critical||0,'#B91C1C']].filter(b=>b[1]>0);\n  if(!bars.length) return '';\n  return `<div style=\"margin-top:18px;\"><div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:12px;\">Server Response Time Distribution \u2014 ${num(total)} pages (Screaming Frog data)</div>\n    ${bars.map(([label,count,color])=>{const p=pct(count,total);return `<div style=\"display:flex;align-items:center;gap:12px;margin-bottom:8px;\"><span style=\"font-size:12px;color:#475569;min-width:160px;\">${label}</span><div style=\"flex:1;height:8px;background:#E2E8F0;border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${p}%;background:${color};border-radius:99px;\"></div></div><span style=\"font-size:12px;font-weight:600;color:#0A1628;min-width:36px;text-align:right;\">${num(count)}</span><span style=\"font-size:11px;color:#94A3B8;min-width:34px;\">${p}%</span></div>`;}).join('')}\n  </div>`;\n}\n\n// \u2500\u2500 BROKEN LINKS TABLE (404/410 only, with Internal/External badge) \u2500\u2500\nfunction buildBrokenTable() {\n  const rows = audit.broken_link_sources||[];\n  const totalBroken404 = real4xxCount;\n  const internalCount  = rows.filter(r=>r.issue_type==='Internal 404').length;\n  if(!rows.length) return `<p style=\"font-size:13px;color:#10B981;padding:12px 0;\">\u2705 No broken pages found.</p>`;\n  return `\n    <div style=\"font-size:12px;color:#475569;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;padding:12px 16px;margin-bottom:14px;line-height:1.7;\">\n      Screaming Frog found <strong style=\"color:#B91C1C;\">${num(totalBroken404)}</strong> unique broken URLs (404/410 errors) across the site\n      \u2014 of which <strong style=\"color:#B91C1C;\">${num(audit.response_codes?.errors_4xx?.internal||0)}</strong> are pages on your own server returning errors,\n      and <strong style=\"color:#92400E;\">${num(audit.response_codes?.errors_4xx?.external||0)}</strong> are external sites you link to that are broken.\n      Top ${Math.min(rows.length,5)} shown below, sorted by inlinks.\n    </div>\n    <table style=\"width:100%;border-collapse:collapse;\">${tableHeader('Type','Broken URL','Code','Inlinks','Found On (sample pages)')}<tbody>\n    ${rows.slice(0,5).map((r,i)=>{\n      const isInt = r.issue_type==='Internal 404';\n      return `<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\">\n        <td style=\"padding:9px 10px;white-space:nowrap;\">\n          <span style=\"font-size:9px;font-weight:700;padding:2px 7px;border-radius:99px;background:${isInt?'#FEE2E2':'#FEF3C7'};color:${isInt?'#B91C1C':'#92400E'};\">\n            ${isInt?'INTERNAL':'EXTERNAL'}\n          </span>\n        </td>\n        <td style=\"padding:9px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;max-width:250px;\">${safe(r.broken_url||'')}</td>\n        <td style=\"padding:9px 10px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">${safe(r.status_code||'404')}</span></td>\n        <td style=\"padding:9px 10px;font-size:13px;font-weight:600;text-align:center;\">${num(r.total_inlinks||0)}</td>\n        <td style=\"padding:9px 14px;font-size:11px;color:#475569;\">${safe((r.source_pages||[]).slice(0,2).join(', '))}</td>\n      </tr>`;\n    }).join('')}\n    </tbody></table>`;\n}\n\n// \u2500\u2500 INTERNAL 403 TABLE (your own site pages blocking crawler) \u2500\u2500\nfunction buildInternal403Table() {\n  const rows = audit.internal_403_links||[];\n  if(!rows.length) return '';\n  return `<div style=\"margin-top:20px;background:#FEF2F2;border:1px solid #FECACA;border-radius:10px;padding:16px 20px;\">\n    <div style=\"font-size:13px;font-weight:700;color:#B91C1C;margin-bottom:8px;\">\u26a0\ufe0f Your Own Site Pages Blocking the Crawler (${rows.length} pages)</div>\n    <div style=\"font-size:12px;color:#1E293B;margin-bottom:12px;line-height:1.7;\">\n      These pages on <strong>${safe(domain)}</strong> returned 403 errors to Screaming Frog \u2014 meaning your WAF or Cloudflare settings\n      may be blocking both SF AND potentially Googlebot. If Googlebot is also blocked, these pages cannot be crawled or indexed at all.\n      Check Cloudflare Bot Management and verify each page opens correctly in a fresh browser tab (Incognito mode).\n    </div>\n    <table style=\"width:100%;border-collapse:collapse;\">${tableHeader('Your Page (Blocked)','Code','Internal Links Pointing to It')}<tbody>\n      ${rows.slice(0,5).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #FECACA;${i%2===1?'background:#FFF5F5;':''}\">\n        <td style=\"padding:8px 12px;font-family:monospace;font-size:11px;color:#DC2626;word-break:break-all;\">${safe(r.broken_url||'')}</td>\n        <td style=\"padding:8px 12px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">${safe(r.status_code)}</span></td>\n        <td style=\"padding:8px 12px;font-size:12px;font-weight:600;text-align:center;\">${num(r.total_inlinks)}</td>\n      </tr>`).join('')}\n    </tbody></table>\n  </div>`;\n}\n\n// \u2500\u2500 MANUAL CHECK 403 TABLE (unknown external 403s \u2014 need manual verification) \u2500\u2500\nfunction buildManualCheck403Table() {\n  const rows = audit.manual_check_links||[];\n  if(!rows.length) return '';\n  return `<div style=\"margin-top:20px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:10px;padding:16px 20px;\">\n    <div style=\"font-size:13px;font-weight:700;color:#92400E;margin-bottom:8px;\">\n      \u26a0\ufe0f External Links Returning 403 \u2014 Needs Manual Check (${num(rows.length)} links)\n    </div>\n    <div style=\"font-size:12px;color:#1E293B;margin-bottom:14px;line-height:1.7;\">\n      These external URLs returned 403 (Forbidden) to the crawler but are <strong>not confirmed bot-blockers</strong>.\n      Open each URL in a fresh browser tab (Incognito):\n      <strong style=\"color:#92400E;\">if it loads</strong> \u2192 the site just blocks crawlers, link is alive, no action needed.\n      <strong style=\"color:#B91C1C;\">If it also shows 403</strong> \u2192 the link is genuinely broken and should be removed or replaced.\n    </div>\n    <table style=\"width:100%;border-collapse:collapse;\">${tableHeader('External URL','Code','Linked From (sample)','Action')}<tbody>\n      ${rows.slice(0,5).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #FDE68A;${i%2===1?'background:#FFFBEB;':''}\">\n        <td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#92400E;word-break:break-all;max-width:280px;\">${safe(r.broken_url||'')}</td>\n        <td style=\"padding:8px 10px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#FEF3C7;color:#92400E;\">${safe(r.status_code)}</span></td>\n        <td style=\"padding:8px 14px;font-size:11px;color:#64748B;\">${safe((r.source_pages||[]).slice(0,1).join(''))}</td>\n        <td style=\"padding:8px 14px;font-size:11px;color:#92400E;font-weight:600;white-space:nowrap;\">Open in browser</td>\n      </tr>`).join('')}\n    </tbody></table>\n  </div>`;\n}\n\n// \u2500\u2500 BOT BLOCKED NOTE (famous platforms \u2014 count only, no table needed) \u2500\u2500\nfunction buildBotBlockedNote() {\n  const count = audit.bot_blocked_count || audit.bot_blocked_links?.length || 0;\n  if(!count) return '';\n  return `<div style=\"margin-top:16px;background:#F8FAFC;border:1px solid #E2E8F0;border-radius:10px;padding:14px 18px;\">\n    <div style=\"display:flex;align-items:center;gap:10px;margin-bottom:6px;\">\n      <span style=\"width:8px;height:8px;border-radius:50%;background:#94A3B8;display:inline-block;flex-shrink:0;\"></span>\n      <div style=\"font-size:13px;font-weight:700;color:#64748B;\">Known Bot-Blocking Platforms \u2014 ${num(count)} links (No Action Needed)</div>\n    </div>\n    <div style=\"font-size:12px;color:#94A3B8;line-height:1.7;\">\n      Your site links to <strong style=\"color:#64748B;\">${num(count)}</strong> URLs on platforms that block all automated crawlers by design\n      \u2014 Twitter/X, LinkedIn, Facebook, Wikipedia, Forbes, BBC and similar high-traffic sites.\n      These links work perfectly in a browser. No action required.\n    </div>\n  </div>`;\n}\n\n// \u2500\u2500 REDIRECT TABLE \u2500\u2500\nfunction buildRedirectTable() {\n  const grouped  = audit.response_codes?.redirects_3xx?.grouped||[];\n  const extCount = audit.response_codes?.redirects_3xx?.external_true_count || 0;\n  if(!grouped.length && !extCount) return `<p style=\"font-size:13px;color:#64748B;padding:16px 0;\">No significant redirect issues found.</p>`;\n  let html = '';\n  if(grouped.length>0){\n    html += `<table style=\"width:100%;border-collapse:collapse;\">\n      ${tableHeader('Type / From URL','Redirects To','Code','Pages','Inlinks')}<tbody>\n      ${grouped.map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\">\n        <td style=\"padding:9px 14px;\">\n          ${r.type==='infrastructure'?`<span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#FEF3C7;color:#92400E;margin-right:6px;\">INFRA</span>`:r.type==='internal'?`<span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#DBEAFE;color:#1E40AF;margin-right:6px;\">INTERNAL</span>`:''}\n          <span style=\"font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;\">${safe(r.label)}</span>\n        </td>\n        <td style=\"padding:9px 14px;font-family:monospace;font-size:11px;color:#475569;word-break:break-all;max-width:180px;\">${safe(r.redirect_to||'')}</td>\n        <td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#DBEAFE;color:#1E40AF;\">${safe(r.code)}</span></td>\n        <td style=\"padding:9px 14px;font-size:13px;font-weight:600;\">${num(r.count)}</td>\n        <td style=\"padding:9px 14px;font-size:13px;\">${num(r.total_inlinks)}</td>\n      </tr>`).join('')}\n      </tbody></table>`;\n  }\n  if(extCount>0){\n    html += `<div style=\"margin-top:18px;background:#F8FAFC;border:1px solid #E2E8F0;border-radius:10px;padding:14px 18px;\">\n      <div style=\"font-size:12px;font-weight:700;color:#64748B;margin-bottom:8px;\">\n        External Links That Redirect \u2014 ${num(extCount)} detected (not your pages to fix)\n      </div>\n      <div style=\"font-size:12px;color:#94A3B8;line-height:1.6;\">\n        Screaming Frog detected <strong style=\"color:#64748B;\">${num(extCount)}</strong> external URLs your site links to that redirect elsewhere\n        (e.g. a LinkedIn URL that redirects to a regional version, or a brand URL that redirects to a campaign page).\n        The redirect happens on the external site \u2014 you cannot control it and it does not affect your SEO score.\n        Updating your outgoing links to point to the final destination URL is a minor optional improvement.\n      </div>\n    </div>`;\n  }\n  return html;\n}\n\n// \u2500\u2500 PRIORITY ACTION TABLE \u2500\u2500\nfunction buildPriorityRows() {\n  const items = [\n    {p:1,issue:'Fix broken pages (404 errors)',             count:real4xxCount,                                                                    effort:2},\n    {p:1,issue:'Fix server errors (5xx)',                   count:audit.response_codes?.errors_5xx?.count||0,                                      effort:3},\n    {p:1,issue:'Fix Poor LCP ('+(psi.lcp?.display||'N/A')+')',count:psi.lcp?.status==='poor'?1:0,                                                effort:3},\n    {p:1,issue:'Add missing H1 headings',                  count:audit.headings?.h1_missing?.count||0,                                            effort:1},\n    {p:1,issue:'Add missing page titles',                  count:audit.page_titles?.missing?.count||0,                                            effort:1},\n    {p:1,issue:'Fix HTTP insecure pages \u2192 HTTPS',          count:audit.security?.http_pages?.count||0,                                            effort:2},\n    {p:1,issue:'Fix exact duplicate pages',                count:audit.content?.exact_duplicates?.count||0,                                       effort:3},\n    {p:2,issue:'Remove '+(psi.opportunities?.unused_javascript?.savings_kb||0)+'KB unused JavaScript',count:psi.opportunities?.unused_javascript?.savings_kb>0?1:0,effort:2},\n    {p:2,issue:'Fix render-blocking CSS ('+(psi.opportunities?.render_blocking?.savings_ms||0)+'ms savings)',count:psi.opportunities?.render_blocking?.savings_ms>0?1:0,effort:2},\n    {p:2,issue:'Fix duplicate H1 headings across pages',   count:audit.headings?.h1_duplicate?.count||0,                                          effort:2},\n    {p:2,issue:'Fix multiple H1 on same page (template fix)',count:audit.headings?.h1_multiple?.count||0,                                         effort:1},\n    {p:2,issue:'Write missing meta descriptions',          count:audit.meta_descriptions?.missing?.count||0,                                      effort:2},\n    {p:2,issue:'Shorten over-long page titles (>60 chars)',count:audit.page_titles?.too_long?.count||0,                                           effort:2},\n    {p:2,issue:'Add alt text to all images',               count:audit.images?.missing_alt?.count||0,                                             effort:2},\n    {p:2,issue:'Add missing canonical tags',               count:audit.canonicals?.missing?.count||0,                                             effort:1},\n    {p:2,issue:'Add internal links to orphan pages',       count:audit.orphan_pages?.count||0,                                                    effort:2},\n    {p:3,issue:'Compress oversized images (>100KB)',       count:audit.images?.oversized?.count||0,                                               effort:1},\n    {p:3,issue:'Review & fix noindex directives',          count:audit.directives?.noindex?.count||0,                                             effort:1},\n    {p:3,issue:'Resolve redirect chains',                  count:audit.redirect_reports?.chains?.count||0,                                        effort:2},\n    {p:3,issue:'Expand thin content pages (<300 words)',   count:audit.content?.thin_content_300?.count||audit.content?.low_content?.count||0,   effort:3},\n    {p:3,issue:'Shorten over-long meta descriptions',      count:audit.meta_descriptions?.too_long?.count||0,                                     effort:1},\n    {p:3,issue:'Add H2 headings to pages without sections',count:audit.headings?.h2_missing?.count||0,                                            effort:2},\n    {p:3,issue:'Fix mixed content (HTTP on HTTPS pages)',  count:audit.security?.mixed_content?.count||0,                                         effort:2},\n    {p:3,issue:'Fix internal pages returning 403 errors',  count:audit.internal_403_links?.length||0,                                             effort:2},\n  ].filter(i=>i.count>0);\n  const effortBars = n => { let d=''; for(let i=1;i<=3;i++) d+=`<div style=\"width:12px;height:6px;border-radius:2px;background:${i<=n?'#2A3F6B':'#E2E8F0'};display:inline-block;margin-right:3px;\"></div>`; return `<div style=\"display:flex;align-items:center;\">${d}</div>`; };\n  const badge = p => { const cfg={1:['#FEE2E2','#B91C1C','URGENT'],2:['#FEF3C7','#92400E','HIGH'],3:['#DBEAFE','#1D4ED8','MEDIUM']}; const[bg,col,lbl]=cfg[p]; return `<span style=\"font-size:9.5px;font-weight:700;padding:3px 10px;border-radius:99px;background:${bg};color:${col};\">${lbl}</span>`; };\n  return items.map((item,i)=>`\n    <tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\">\n      <td style=\"padding:10px 14px;font-weight:700;color:#0A1628;font-size:13px;\">${i+1}</td>\n      <td style=\"padding:10px 14px;font-size:13px;color:#1E293B;\">${safe(item.issue)}</td>\n      <td style=\"padding:10px 14px;font-size:13px;font-weight:600;color:#0A1628;\">${item.count===1&&item.p===1&&item.issue.startsWith('Fix Poor')?'Homepage':num(item.count)}</td>\n      <td style=\"padding:10px 14px;\">${effortBars(item.effort)}</td>\n      <td style=\"padding:10px 14px;\">${badge(item.p)}</td>\n    </tr>`).join('');\n}\n\n// \u2500\u2500 CRAWL STATS CARDS \u2500\u2500\nconst crawlStats = [\n  {label:'Total URLs Crawled', value:num(trueTotalCrawled),             sub:'all content types'},\n  {label:'Indexable Pages',    value:num(trueIndexable),                sub:`of ${num(trueTotalCrawled)} total`},\n  {label:'Non-Indexable',      value:num(audit.meta.non_indexable),     sub:'blocked from Google'},\n  {label:'SF Avg Response',    value:avgRT+'s',                         sub:avgRTLabel},\n  {label:'Avg Word Count',     value:num(audit.meta.avg_word_count),    sub:'words per page'},\n  {label:'Crawl Date',         value:audit.meta.crawl_date,             sub:'audit snapshot'},\n];\n\n// \u2500\u2500 WORD COUNT BARS \u2500\u2500\nfunction buildWordCountBars() {\n  const wc=audit.meta.word_count_buckets||{};\n  const total=Object.values(wc).reduce((a,b)=>a+b,0)||1;\n  const bars=[['Empty (0 words)',wc.empty||0,'#EF4444'],['Thin (1\u2013200 words)',wc.thin||0,'#F59E0B'],['Medium (201\u2013600 words)',wc.medium||0,'#6366F1'],['Good (601\u20131500 words)',wc.good||0,'#10B981'],['Rich (1500+ words)',wc.rich||0,'#059669']].filter(b=>b[1]>0);\n  if(!bars.length) return '';\n  return bars.map(([label,count,color])=>{const p=pct(count,total);return `<div style=\"display:flex;align-items:center;gap:12px;margin-bottom:8px;\"><span style=\"font-size:12px;color:#475569;min-width:180px;\">${label}</span><div style=\"flex:1;height:8px;background:#E2E8F0;border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${p}%;background:${color};border-radius:99px;\"></div></div><span style=\"font-size:12px;font-weight:600;color:#0A1628;min-width:36px;text-align:right;\">${num(count)}</span><span style=\"font-size:11px;color:#94A3B8;min-width:34px;\">${p}%</span></div>`;}).join('');\n}\n\n// \u2500\u2500 CRAWL DEPTH BARS \u2500\u2500\nfunction buildDepthBars() {\n  const d=audit.meta.depth_buckets||{};\n  const total=Object.values(d).reduce((a,b)=>a+b,0)||1;\n  const bars=[['Depth 1 (homepage level)',d.d1||0,'#10B981'],['Depth 2',d.d2||0,'#34D399'],['Depth 3',d.d3||0,'#F59E0B'],['Depth 4',d.d4||0,'#EF4444'],['Depth 5+',d.d5plus||0,'#B91C1C']].filter(b=>b[1]>0);\n  if(!bars.length) return '<p style=\"font-size:12px;color:#94A3B8;\">Depth data not available.</p>';\n  return bars.map(([label,count,color])=>{const p=pct(count,total);return `<div style=\"display:flex;align-items:center;gap:12px;margin-bottom:8px;\"><span style=\"font-size:12px;color:#475569;min-width:180px;\">${label}</span><div style=\"flex:1;height:8px;background:#E2E8F0;border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${p}%;background:${color};border-radius:99px;\"></div></div><span style=\"font-size:12px;font-weight:600;color:#0A1628;min-width:36px;text-align:right;\">${num(count)}</span><span style=\"font-size:11px;color:#94A3B8;min-width:34px;\">${p}%</span></div>`;}).join('');\n}\n\n// \u2500\u2500 CRAWL OVERVIEW FULL BREAKDOWN \u2500\u2500\nfunction buildCrawlOverview() {\n  if(!co) return '';\n  const totalCrawled=co.total_urls_crawled||0;\n  const intAll=co.internal?.all||0; const intHtml=co.internal?.html||0; const intImages=co.internal?.images||0;\n  const intCss=co.internal?.css||0; const intJs=co.internal?.javascript||0; const intPdf=co.internal?.pdf||0;\n  const intOther=co.internal?.other||0; const intIndexable=co.internal?.indexable||audit.meta.indexable||0;\n  const intNonIndexable=co.internal?.non_indexable||audit.meta.non_indexable||0;\n  const extAll=co.external?.all||(totalCrawled-intAll)||0;\n  const rc=co.response_codes||{};\n  const int2xx=rc.internal_2xx||0; const int3xx=rc.internal_3xx||0; const int4xx=rc.internal_4xx||0; const int5xx=rc.internal_5xx||0;\n  const ext2xx=rc.external_2xx||0; const ext3xx=rc.external_3xx||0; const ext4xx=rc.external_4xx||0; const noResp=rc.no_response||0;\n  const imgAll=co.images_overview?.all||0; const imgOver100=co.images_overview?.over_100kb||0; const imgNoAlt=co.images_overview?.missing_alt||0;\n  const chip=(label,val,color)=>`<div style=\"display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:0.5px solid #F1F5F9;\"><span style=\"font-size:12px;color:#475569;\">${label}</span><span style=\"font-size:12.5px;font-weight:700;color:${color};\">${num(val)}</span></div>`;\n  const barRow=(label,val,total,color)=>{const p=pct(val,total);return `<div style=\"display:flex;align-items:center;gap:10px;margin-bottom:6px;\"><span style=\"font-size:11.5px;color:#475569;min-width:140px;\">${label}</span><div style=\"flex:1;height:7px;background:#E2E8F0;border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${p}%;background:${color};border-radius:99px;\"></div></div><span style=\"font-size:11.5px;font-weight:600;color:#0A1628;min-width:32px;text-align:right;\">${num(val)}</span><span style=\"font-size:10.5px;color:#94A3B8;min-width:30px;\">${p}%</span></div>`;};\n  return `<div style=\"margin-bottom:24px;\">\n    <div style=\"font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:14px;\">\ud83d\udcca Crawl Overview \u2014 Full Breakdown</div>\n    <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:14px;\">\n      <div style=\"background:#0A1628;border-radius:11px;padding:16px 18px;color:white;\">\n        <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:rgba(255,255,255,0.4);margin-bottom:8px;\">Total URLs Crawled</div>\n        <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:32px;font-weight:800;letter-spacing:-1px;color:white;\">${num(totalCrawled)}</div>\n        <div style=\"margin-top:10px;\">\n          <div style=\"display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px;\"><span style=\"color:rgba(255,255,255,0.45);\">Internal</span><span style=\"color:#6EE7B7;font-weight:600;\">${num(intAll)} (${pct(intAll,totalCrawled)}%)</span></div>\n          <div style=\"display:flex;justify-content:space-between;font-size:11px;\"><span style=\"color:rgba(255,255,255,0.45);\">External</span><span style=\"color:#93C5FD;font-weight:600;\">${num(extAll)} (${pct(extAll,totalCrawled)}%)</span></div>\n        </div>\n      </div>\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:11px;padding:16px 18px;\">\n        <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:10px;\">Internal URLs \u2014 by Type</div>\n        ${chip('HTML Pages',intHtml,'#2563EB')}${chip('Images',intImages,'#10B981')}${chip('CSS Files',intCss,'#6366F1')}${chip('JavaScript',intJs,'#F59E0B')}${chip('PDF',intPdf,'#EF4444')}${intOther>0?chip('Other',intOther,'#94A3B8'):''}\n      </div>\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:11px;padding:16px 18px;\">\n        <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:10px;\">Indexability</div>\n        <div style=\"text-align:center;padding:8px 0 12px;\">\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:#10B981;\">${num(intIndexable)}</div>\n          <div style=\"font-size:11px;color:#64748B;\">indexable pages</div>\n          <div style=\"height:8px;background:#E2E8F0;border-radius:99px;overflow:hidden;margin:10px 0 6px;\"><div style=\"height:100%;width:${pct(intIndexable,intAll)}%;background:linear-gradient(90deg,#10B981,#34D399);border-radius:99px;\"></div></div>\n        </div>\n        ${chip('Indexable',intIndexable,'#10B981')}${chip('Non-Indexable',intNonIndexable,'#F59E0B')}${chip('All Internal',intAll,'#64748B')}\n      </div>\n    </div>\n    <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px;\">\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:11px;padding:16px 18px;\">\n        <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:12px;\">Response Codes</div>\n        <div style=\"font-size:11px;font-weight:600;color:#64748B;margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid #E2E8F0;\">Internal URLs</div>\n        ${barRow('Success (2xx)',int2xx,intAll,'#10B981')}${barRow('Redirects (3xx)',int3xx,intAll,'#F59E0B')}${barRow('Client Error (4xx)',int4xx,intAll,'#EF4444')}${int5xx>0?barRow('Server Error (5xx)',int5xx,intAll,'#B91C1C'):''}\n        ${ext2xx>0||ext3xx>0||ext4xx>0?`<div style=\"font-size:11px;font-weight:600;color:#64748B;margin:10px 0 8px;padding-bottom:4px;border-bottom:1px solid #E2E8F0;\">External URLs</div>${ext2xx>0?barRow('Success (2xx)',ext2xx,extAll,'#34D399'):''}${ext3xx>0?barRow('Redirects (3xx)',ext3xx,extAll,'#FCD34D'):''}${ext4xx>0?barRow('Client Error (4xx)',ext4xx,extAll,'#FCA5A5'):''}${noResp>0?barRow('No Response',noResp,totalCrawled,'#CBD5E1'):''}`:''}</div>\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:11px;padding:16px 18px;\">\n        <div style=\"font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:12px;\">Images Crawled</div>\n        <div style=\"text-align:center;padding:6px 0 14px;\">\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:34px;font-weight:800;color:#6366F1;letter-spacing:-1px;\">${num(imgAll>0?imgAll:intImages)}</div>\n          <div style=\"font-size:11px;color:#64748B;margin-top:2px;\">total images found with 2xx response</div>\n        </div>\n        <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:10px;\">\n          <div style=\"padding:12px 14px;background:${imgOver100>0?'#FEF3C7':'#ECFDF5'};border-radius:9px;border:1px solid ${imgOver100>0?'#FDE68A':'#A7F3D0'};\">\n            <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:${imgOver100>0?'#92400E':'#065F46'};margin-bottom:4px;\">Over 100KB</div>\n            <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:24px;font-weight:800;color:${imgOver100>0?'#B45309':'#10B981'};\">${num(imgOver100)}</div>\n            <div style=\"font-size:10px;color:#64748B;margin-top:2px;\">${pct(imgOver100,imgAll||intImages)}% of images</div>\n          </div>\n          <div style=\"padding:12px 14px;background:${imgNoAlt>0?'#FEF2F2':'#ECFDF5'};border-radius:9px;border:1px solid ${imgNoAlt>0?'#FECACA':'#A7F3D0'};\">\n            <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:${imgNoAlt>0?'#B91C1C':'#065F46'};margin-bottom:4px;\">Missing Alt</div>\n            <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:24px;font-weight:800;color:${imgNoAlt>0?'#DC2626':'#10B981'};\">${num(imgNoAlt)}</div>\n            <div style=\"font-size:10px;color:#64748B;margin-top:2px;\">${pct(imgNoAlt,imgAll||intImages)}% of images</div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>`;\n}\n\n// ================================================================\n// FINAL HTML REPORT\n// ================================================================\nconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\"/>\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>\n<title>SEO Audit \u2014 ${safe(domain)}</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,700;12..96,800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap\" rel=\"stylesheet\"/>\n<style>\n*{margin:0;padding:0;box-sizing:border-box;}\nbody{font-family:'DM Sans',sans-serif;background:#F1F5F9;color:#1E293B;font-size:14px;line-height:1.6;-webkit-print-color-adjust:exact;print-color-adjust:exact;color-adjust:exact;}\n.report{max-width:960px;margin:0 auto;background:#ffffff;}\n.section{padding:36px 44px;border-bottom:1px solid #E2E8F0;}\n.section:last-of-type{border-bottom:none;}\ntable{width:100%;border-collapse:collapse;}\n@page{size:A4;margin:12mm 14mm 14mm 14mm;}\n@page :first{margin:0;}\n@media print{\n  body{background:white;font-size:11px;}\n  .report{max-width:100%;box-shadow:none;}\n  .section{padding:24px 44px !important;}\n  tr,td,th,div[style*=\"display:flex\"],div[style*=\"display:grid\"]>div{page-break-inside:avoid !important;break-inside:avoid !important;}\n  table{page-break-inside:auto;}\n  thead{display:table-header-group;}\n  *{-webkit-print-color-adjust:exact !important;print-color-adjust:exact !important;color-adjust:exact !important;}\n}\n.watermark{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-38deg);font-size:120px;font-weight:800;font-style:italic;letter-spacing:8px;color:rgba(120,120,120,0.07);z-index:9999;pointer-events:none;white-space:nowrap;}\n</style>\n</head>\n<body>\n${wm?`<div class=\"watermark\">${wm}</div>`:''}\n<div class=\"report\">\n\n<!-- \u2550\u2550\u2550 COVER \u2550\u2550\u2550 -->\n<div style=\"background:#0A1628;position:relative;overflow:hidden;min-height:100vh;display:flex;flex-direction:column;\">\n  <div style=\"position:absolute;top:-100px;right:-100px;width:400px;height:400px;border-radius:50%;background:radial-gradient(circle,rgba(99,102,241,0.25) 0%,transparent 65%);pointer-events:none;\"></div>\n  <div style=\"position:absolute;bottom:-60px;left:60px;width:260px;height:260px;border-radius:50%;background:radial-gradient(circle,rgba(37,99,235,0.18) 0%,transparent 65%);pointer-events:none;\"></div>\n  <div style=\"position:absolute;top:40%;left:-80px;width:180px;height:180px;border-radius:50%;background:radial-gradient(circle,rgba(16,185,129,0.12) 0%,transparent 65%);pointer-events:none;\"></div>\n  <div style=\"display:flex;align-items:center;justify-content:space-between;padding:24px 44px;border-bottom:1px solid rgba(255,255,255,0.07);position:relative;z-index:1;\">\n    <div style=\"display:flex;align-items:center;gap:14px;\">\n      <div style=\"width:42px;height:42px;background:linear-gradient(135deg,#6366F1,#4F46E5);border-radius:11px;display:flex;align-items:center;justify-content:center;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:16px;color:white;box-shadow:0 4px 14px rgba(99,102,241,0.4);\">${safe(AGENCY.initial)}</div>\n      <div>\n        <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-weight:700;font-size:16px;color:white;\">${safe(AGENCY.name)}</div>\n        <div style=\"font-size:11.5px;color:rgba(255,255,255,0.4);margin-top:1px;\">${safe(AGENCY.tagline)}</div>\n      </div>\n    </div>\n    <div style=\"text-align:right;\">\n      <div style=\"font-size:10px;color:rgba(255,255,255,0.35);text-transform:uppercase;letter-spacing:1.2px;font-weight:600;\">Report Generated</div>\n      <div style=\"font-size:14px;color:rgba(255,255,255,0.8);font-weight:500;margin-top:2px;\">${audit.meta.crawl_date}</div>\n      <div style=\"font-size:10px;color:rgba(255,255,255,0.35);text-transform:uppercase;letter-spacing:1.2px;font-weight:600;margin-top:8px;\">Prepared For</div>\n      <div style=\"font-size:14px;color:rgba(255,255,255,0.8);font-weight:500;margin-top:2px;\">${safe(domain)}</div>\n    </div>\n  </div>\n  <div style=\"padding:52px 44px 44px;position:relative;z-index:1;flex:1;\">\n    <div style=\"display:inline-flex;align-items:center;gap:8px;background:rgba(99,102,241,0.18);border:1px solid rgba(99,102,241,0.35);color:#A5B4FC;font-size:11px;font-weight:600;letter-spacing:1.5px;text-transform:uppercase;padding:6px 14px;border-radius:24px;margin-bottom:22px;\"><div style=\"width:6px;height:6px;border-radius:50%;background:#818CF8;\"></div>Technical SEO Audit Report</div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:46px;font-weight:800;color:white;line-height:1.05;letter-spacing:-2px;margin-bottom:10px;\">${safe(domain)}</div>\n    <div style=\"font-size:15px;color:rgba(255,255,255,0.4);margin-bottom:40px;\">Full technical crawl audit \u2014 ${num(trueTotalCrawled)} URLs analysed \u00b7 Mobile performance tested with Google Lighthouse ${safe(psi.lighthouse_version||'')}</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:14px;overflow:hidden;margin-bottom:20px;\">\n      ${[\n        ['URLs Crawled',num(trueTotalCrawled),'total URLs found','rgba(255,255,255,0.95)'],\n        ['Broken Pages',num(real4xxCount),'genuine 404 errors',real4xxCount>0?'#FC8181':'#6EE7B7'],\n        ['Perf Score',perfScore+'/100',perfLabel,perfScore>=90?'#6EE7B7':perfScore>=50?'#FCD34D':'#FC8181'],\n        ['LCP',psi.lcp?.display||avgRT+'s',psi.lcp?.status||avgRTLabel,psi.lcp?.status==='good'?'#6EE7B7':psi.lcp?.status==='needs_improvement'?'#FCD34D':'#FC8181'],\n      ].map(([label,val,sub,color])=>`\n        <div style=\"padding:20px 22px;border-right:1px solid rgba(255,255,255,0.06);\">\n          <div style=\"font-size:10px;color:rgba(255,255,255,0.32);text-transform:uppercase;letter-spacing:1.1px;font-weight:600;margin-bottom:8px;\">${label}</div>\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:26px;font-weight:800;line-height:1;letter-spacing:-1px;color:${color};\">${val}</div>\n          <div style=\"font-size:11px;color:rgba(255,255,255,0.3);margin-top:5px;\">${sub}</div>\n        </div>`).join('')}\n    </div>\n    <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:16px;\">\n      <div style=\"background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:20px 24px;display:flex;align-items:center;gap:20px;\">\n        <div>\n          <div style=\"font-size:10px;color:rgba(255,255,255,0.35);text-transform:uppercase;letter-spacing:1.1px;font-weight:600;margin-bottom:4px;\">Site Health Score</div>\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:44px;font-weight:800;color:${scoreColor};line-height:1;letter-spacing:-2px;\">${audit.health.score}</div>\n          <div style=\"font-size:12px;color:rgba(255,255,255,0.4);margin-top:4px;\">out of 100 \u00b7 ${audit.health.label}</div>\n        </div>\n        <div style=\"flex:1;\">\n          <div style=\"height:10px;background:rgba(255,255,255,0.08);border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${audit.health.score}%;background:linear-gradient(90deg,#3B82F6,${scoreColor});border-radius:99px;\"></div></div>\n          <div style=\"display:flex;justify-content:space-between;margin-top:6px;\">${['Critical','Poor','Needs Work','Good','Excellent'].map(l=>`<span style=\"font-size:9px;color:rgba(255,255,255,0.22);\">${l}</span>`).join('')}</div>\n        </div>\n      </div>\n      <div style=\"background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:20px 24px;display:flex;align-items:center;gap:20px;\">\n        <div>\n          <div style=\"font-size:10px;color:rgba(255,255,255,0.35);text-transform:uppercase;letter-spacing:1.1px;font-weight:600;margin-bottom:4px;\">Performance Score</div>\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:44px;font-weight:800;color:${perfColor};line-height:1;letter-spacing:-2px;\">${perfScore}</div>\n          <div style=\"font-size:12px;color:rgba(255,255,255,0.4);margin-top:4px;\">out of 100 \u00b7 ${perfLabel}</div>\n        </div>\n        <div style=\"flex:1;\">\n          <div style=\"height:10px;background:rgba(255,255,255,0.08);border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${perfScore}%;background:linear-gradient(90deg,#3B82F6,${perfColor});border-radius:99px;\"></div></div>\n          <div style=\"display:flex;justify-content:space-between;margin-top:6px;\">${['0','25','50','75','100'].map(l=>`<span style=\"font-size:9px;color:rgba(255,255,255,0.22);\">${l}</span>`).join('')}</div>\n        </div>\n      </div>\n    </div>\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 1: EXECUTIVE SUMMARY \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udccb','Executive Summary',null,'#2563EB')}\n  <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:24px;\">\n    ${[\n      ['\ud83d\udd34 Critical Issues',audit.health.critical_issues,'Broken pages, server errors, missing titles & H1s','#FEF2F2','#FECACA','#B91C1C'],\n      ['\ud83d\udfe1 Warnings',audit.health.warnings,'Meta issues, redirect problems, image optimisation','#FFFBEB','#FDE68A','#B45309'],\n      ['\u2705 Indexable Pages',trueIndexable,`of ${num(trueTotalCrawled)} URLs visible to Google`,'#ECFDF5','#A7F3D0','#065F46'],\n    ].map(([label,val,sub,bg,bdr,col])=>`\n      <div style=\"padding:20px 22px;background:${bg};border-radius:12px;border:1px solid ${bdr};\">\n        <div style=\"font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:${col};margin-bottom:8px;\">${label}</div>\n        <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:38px;font-weight:800;line-height:1;letter-spacing:-1.5px;color:${col};\">${num(val)}</div>\n        <div style=\"font-size:12px;margin-top:6px;color:${col};opacity:0.75;\">${sub}</div>\n      </div>`).join('')}\n  </div>\n  <div style=\"display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:24px;\">\n    ${crawlStats.map(s=>`\n      <div style=\"padding:14px 18px;background:#F8FAFC;border-radius:10px;border:0.5px solid #E2E8F0;\">\n        <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:4px;\">${safe(s.label)}</div>\n        <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:700;color:#0A1628;letter-spacing:-0.5px;\">${safe(s.value)}</div>\n        <div style=\"font-size:11.5px;color:#64748B;margin-top:2px;\">${safe(s.sub)}</div>\n      </div>`).join('')}\n  </div>\n  ${buildCrawlOverview()}\n  <div style=\"background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:22px 24px;\">\n    <div style=\"font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:16px;\">\ud83c\udfaf Top Priority Actions</div>\n    ${priorityCandidates.map((p,i)=>{\n      const bdg=[['#FEE2E2','#B91C1C','#1 URGENT'],['#FEF3C7','#92400E','#2 HIGH'],['#DBEAFE','#1D4ED8','#3 MEDIUM']][i];\n      return `<div style=\"display:flex;align-items:flex-start;gap:12px;margin-bottom:${i<2?'12px':'0'};\">\n        <span style=\"font-size:10px;font-weight:700;padding:3px 10px;border-radius:99px;background:${bdg[0]};color:${bdg[1]};white-space:nowrap;margin-top:2px;flex-shrink:0;\">${bdg[2]}</span>\n        <span style=\"font-size:13.5px;color:#1E293B;line-height:1.55;\">${safe(p.t)}</span>\n      </div>`;}).join('')}\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 2: ALL ISSUES DETECTED \u2550\u2550\u2550 -->\n${(audit.issues_overview||[]).filter(i=>i.count>0).length>0?`\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udcca','All Issues Detected',(audit.issues_overview||[]).filter(i=>i.count>0).length,'#F59E0B')}\n  ${audit.security_header_noise?.count>0?`<div style=\"background:#F8FAFC;border:1px solid #E2E8F0;border-radius:10px;padding:12px 16px;margin-bottom:16px;font-size:12px;color:#64748B;line-height:1.6;\"><strong style=\"color:#0A1628;\">Note on security headers:</strong> ${audit.security_header_noise.issues?.length||0} server-level security header warnings (HSTS, X-Frame-Options, CSP etc.) affecting ~${num(audit.security_header_noise.count)} URLs are collapsed at the bottom. These are server configuration items \u2014 not SEO issues.</div>`:''}\n  <table style=\"border-collapse:collapse;\">${tableHeader('Issue Name','Type','Priority','Affected URLs','% of Site')}<tbody>${buildSFIssues()}</tbody></table>\n</div>`:''}\n\n<!-- \u2550\u2550\u2550 SECTION 3: CORE WEB VITALS & PAGE SPEED \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\u26a1','Core Web Vitals & Page Speed',null,'#F59E0B')}\n  <div style=\"background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:18px 22px;margin-bottom:24px;display:flex;align-items:center;gap:20px;\">\n    <div>\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:#94A3B8;margin-bottom:4px;\">Google Lighthouse Score</div>\n      <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:48px;font-weight:800;color:${perfColor};line-height:1;letter-spacing:-2px;\">${perfScore}</div>\n      <div style=\"font-size:12px;color:#64748B;margin-top:4px;\">${perfLabel} \u00b7 Mobile \u00b7 Lighthouse ${safe(psi.lighthouse_version||'')}</div>\n    </div>\n    <div style=\"flex:1;\">\n      <div style=\"height:12px;background:#E2E8F0;border-radius:99px;overflow:hidden;margin-bottom:10px;\"><div style=\"height:100%;width:${perfScore}%;background:linear-gradient(90deg,#EF4444,#F59E0B,#10B981);border-radius:99px;\"></div></div>\n      <div style=\"font-size:12px;color:#64748B;line-height:1.7;\">Tested: <span style=\"font-family:monospace;color:#1D4ED8;\">${safe(psi.tested_url||domain)}</span> on mobile (simulates Moto G4 on 4G). ${psi.diagnostics?.total_requests||0} requests, ${psi.diagnostics?.total_size_kb||0}KB total weight.</div>\n    </div>\n  </div>\n  <div style=\"font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:12px;\">Core Web Vitals (Direct Google Ranking Factors)</div>\n  <div style=\"display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:24px;\">\n    ${cwvCard('Largest Contentful Paint',psi.lcp?.display||'\u2014',psi.lcp?.status||'unknown','Hero image load time','Good < 2.5s','Poor > 4s')}\n    ${cwvCard('Cumulative Layout Shift',psi.cls?.display||'\u2014',psi.cls?.status||'unknown','Visual stability score','Good < 0.1','Poor > 0.25')}\n    ${cwvCard('Total Blocking Time',psi.tbt?.display||'\u2014',psi.tbt?.status||'unknown','JS blocking main thread','Good < 200ms','Poor > 600ms')}\n  </div>\n  <div style=\"font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:12px;\">Additional Performance Metrics</div>\n  <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:24px;\">\n    ${[['First Contentful Paint',psi.fcp?.display||'\u2014',psi.fcp?.status],['Speed Index',psi.si?.display||'\u2014',psi.si?.status],['Time to Interactive',psi.tti?.display||'\u2014',psi.tti?.status],['Time to First Byte',psi.ttfb?.display||'\u2014',psi.ttfb?.status]].map(([label,val,status])=>{\n      const col=cwvColor(status||'unknown'); const bg=cwvBg(status||'unknown');\n      return `<div style=\"padding:14px 16px;background:${bg};border-radius:10px;text-align:center;\"><div style=\"font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:${col};margin-bottom:6px;\">${label}</div><div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:800;color:${col};\">${val}</div><div style=\"font-size:10px;font-weight:600;margin-top:4px;color:${col};\">${cwvLabel(status||'unknown')}</div></div>`;}).join('')}\n  </div>\n  ${psi.lcp?.element||psi.lcp?.issue?`\n  <div style=\"background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;padding:14px 18px;margin-bottom:20px;\">\n    <div style=\"font-size:11px;font-weight:700;color:#92400E;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:8px;\">\ud83c\udfaf LCP Element Identified</div>\n    ${psi.lcp?.issue?`<div style=\"font-size:12.5px;color:#1E293B;margin-bottom:6px;font-weight:600;\">Issue: ${safe(psi.lcp.issue)}</div>`:''}\n    ${psi.lcp?.element?`<div style=\"font-family:monospace;font-size:11px;color:#92400E;background:white;padding:8px 12px;border-radius:6px;word-break:break-all;\">${safe(psi.lcp.element)}</div>`:''}\n    ${psi.lcp?.breakdown?.length>0?`<div style=\"margin-top:10px;display:grid;grid-template-columns:repeat(${psi.lcp.breakdown.length},1fr);gap:8px;\">${psi.lcp.breakdown.map(b=>`<div style=\"text-align:center;padding:8px;background:rgba(0,0,0,0.04);border-radius:6px;\"><div style=\"font-size:9px;color:#64748B;text-transform:uppercase;font-weight:600;\">${safe(b.label)}</div><div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:16px;font-weight:700;color:#0A1628;\">${b.duration_ms}ms</div></div>`).join('')}</div>`:''}\n  </div>`:''} \n  <div style=\"font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:12px;\">Performance Opportunities (Estimated Savings)</div>\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;\">\n    ${[['Unused JavaScript',psi.opportunities?.unused_javascript?.savings_kb,'KB','#EF4444',psi.opportunities?.unused_javascript?.score===0],['Unused CSS',psi.opportunities?.unused_css?.savings_kb,'KB','#F59E0B',psi.opportunities?.unused_css?.score===0],['Render-Blocking CSS',psi.opportunities?.render_blocking?.savings_ms,'ms','#F59E0B',(psi.opportunities?.render_blocking?.savings_ms||0)>200],['Browser Caching',psi.opportunities?.cache_policy?.savings_kb,'KB','#6366F1',psi.opportunities?.cache_policy?.score===0]].filter(o=>(o[1]||0)>0).map(([label,val,unit,col,isIssue])=>`\n      <div style=\"padding:14px 18px;background:${isIssue?'#FEF2F2':'#F8FAFC'};border-radius:10px;border:1px solid ${isIssue?'#FECACA':'#E2E8F0'};display:flex;align-items:center;gap:14px;\">\n        <div>\n          <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:${isIssue?col:'#64748B'};margin-bottom:4px;\">${label}</div>\n          <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:800;color:${isIssue?col:'#0A1628'};\">~${val} ${unit} savings</div>\n        </div>\n        ${isIssue?`<span style=\"margin-left:auto;font-size:10px;font-weight:700;padding:3px 10px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">FIX</span>`:''}\n      </div>`).join('')}\n  </div>\n  ${(psi.opportunities?.unused_javascript?.items||[]).length>0?`\n  <div style=\"margin-bottom:18px;\">\n    <div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:10px;\">Unused JavaScript Files (remove or defer)</div>\n    <table>${tableHeader('Script URL','Savings (KB)')}<tbody>\n    ${psi.opportunities.unused_javascript.items.map((item,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;\">${safe(item.url)}</td><td style=\"padding:8px 14px;\"><span style=\"font-size:11px;font-weight:600;padding:2px 9px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">${item.savings_kb} KB</span></td></tr>`).join('')}\n    </tbody></table>\n  </div>`:''}\n  ${(psi.opportunities?.render_blocking?.items||[]).length>0?`\n  <div style=\"margin-bottom:18px;\">\n    <div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:10px;\">Render-Blocking Resources (add defer/async or inline critical CSS)</div>\n    <table>${tableHeader('Resource URL','Time Wasted','Size (KB)')}<tbody>\n    ${psi.opportunities.render_blocking.items.slice(0,8).map((item,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;max-width:380px;\">${safe(item.url)}</td><td style=\"padding:8px 14px;\"><span style=\"font-size:11px;font-weight:600;padding:2px 9px;border-radius:99px;background:#FEF3C7;color:#92400E;\">${item.wasted_ms}ms</span></td><td style=\"padding:8px 14px;font-size:12px;color:#64748B;\">${item.size_kb} KB</td></tr>`).join('')}\n    </tbody></table>\n  </div>`:''}\n  <div style=\"font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#94A3B8;margin-bottom:12px;\">Page Weight Breakdown (${psi.diagnostics?.total_size_kb||0}KB total, ${psi.diagnostics?.total_requests||0} requests)</div>\n  <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:20px;\">\n    ${(psi.resource_breakdown||[]).filter(r=>r.requests>0&&r.size_kb>0).map(r=>{\n      const colors={Image:'#10B981',Script:'#EF4444',Font:'#8B5CF6',Stylesheet:'#F59E0B',Document:'#2563EB',Other:'#94A3B8','Third-party':'#EC4899'};\n      const col=colors[r.type]||'#64748B';\n      return `<div style=\"padding:12px 14px;background:#F8FAFC;border-radius:9px;border-left:3px solid ${col};\"><div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:${col};margin-bottom:4px;\">${r.type}</div><div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:18px;font-weight:700;color:#0A1628;\">${r.size_kb} KB</div><div style=\"font-size:11px;color:#64748B;margin-top:2px;\">${r.requests} request${r.requests!==1?'s':''}</div></div>`;}).join('')}\n  </div>\n  ${buildSpeedBars()}\n  <div style=\"margin-top:20px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;\">\n    ${[['Long JS Tasks',psi.diagnostics?.long_tasks||0,'tasks > 50ms','#F59E0B'],['Layout Shifts',psi.diagnostics?.layout_shifts||0,'visual shifts','#F59E0B'],['Main Thread',(psi.diagnostics?.main_thread_ms||0)+'ms','total blocking','#EF4444'],['Unsized Images',(psi.diagnostics?.unsized_images||[]).length,'missing width/height','#6366F1']].map(([label,val,sub,col])=>`\n      <div style=\"padding:12px 14px;background:#F8FAFC;border-radius:9px;border:0.5px solid #E2E8F0;\"><div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#94A3B8;margin-bottom:4px;\">${label}</div><div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:18px;font-weight:700;color:${col};\">${val}</div><div style=\"font-size:11px;color:#64748B;margin-top:2px;\">${sub}</div></div>`).join('')}\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 4: RESPONSE CODES & CRAWLABILITY \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udd34','Response Codes & Crawlability',(real4xxCount)+(audit.response_codes?.errors_5xx?.count||0),'#EF4444')}\n  <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px;\">\n    ${responseCards.map(card=>{\n      const p=pct(card.total,coTotal);\n      return `<div style=\"padding:18px 20px;background:${card.bg};border-radius:11px;border:1px solid ${card.bdr};\">\n        <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.8px;color:${card.col};margin-bottom:6px;\">${card.label}</div>\n        <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:${card.col};letter-spacing:-1px;\">${num(card.total)}</div>\n        <div style=\"margin-top:8px;height:5px;background:rgba(0,0,0,0.08);border-radius:99px;overflow:hidden;\"><div style=\"height:100%;width:${p}%;background:${card.col};border-radius:99px;\"></div></div>\n        <div style=\"font-size:11px;color:${card.col};opacity:0.7;margin-top:5px;\">${p}% of total URLs</div>\n        <div style=\"margin-top:8px;font-size:10px;color:${card.col};opacity:0.8;display:flex;gap:12px;\">\n          ${card.internal>0?`<span>\u21b3 Internal: <strong>${num(card.internal)}</strong></span>`:''}\n          ${card.external>0?`<span style=\"opacity:0.65;\">\u00b7 External: <strong>${num(card.external)}</strong></span>`:''}\n        </div>\n        <div style=\"font-size:10px;color:${card.col};opacity:0.5;margin-top:3px;\">${card.sub}</div>\n      </div>`;}).join('')}\n  </div>\n  <div style=\"margin-bottom:28px;\">\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:14px;display:flex;align-items:center;gap:8px;\">\n      <span style=\"width:8px;height:8px;border-radius:50%;background:#EF4444;display:inline-block;\"></span>\n      Genuinely Broken Pages \u2014 404 Errors (${num(real4xxCount)} unique broken URLs found)\n    </div>\n    ${buildBrokenTable()}\n    ${buildInternal403Table()}\n    ${buildManualCheck403Table()}\n    ${buildBotBlockedNote()}\n  </div>\n  ${(audit.response_codes?.errors_5xx?.count||0)>0?`\n  <div style=\"margin-bottom:28px;\">\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:14px;display:flex;align-items:center;gap:8px;\"><span style=\"width:8px;height:8px;border-radius:50%;background:#B91C1C;display:inline-block;\"></span>Server Errors \u2014 5xx (${num(audit.response_codes?.errors_5xx?.count)} total)</div>\n    <table>${tableHeader('URL','Status Code','Status','Inlinks')}<tbody>\n    ${(audit.response_codes?.errors_5xx?.rows||[]).slice(0,5).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:9px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;\">${safe(r.url||'')}</td><td style=\"padding:9px 14px;\"><span style=\"font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">${safe(r.code||'')}</span></td><td style=\"padding:9px 14px;font-size:13px;\">${safe(r.status||'')}</td><td style=\"padding:9px 14px;font-size:13px;font-weight:600;\">${num(r.inlinks||0)}</td></tr>`).join('')}\n    </tbody></table>\n  </div>`:''}\n  <div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:14px;display:flex;align-items:center;gap:8px;\">\n      <span style=\"width:8px;height:8px;border-radius:50%;background:#F59E0B;display:inline-block;\"></span>\n      Redirect Analysis \u2014 ${num(audit.response_codes?.redirects_3xx?.count||0)} internal redirects\n    </div>\n    ${buildRedirectTable()}\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 5: ON-PAGE SEO \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udcdd','On-Page SEO',(audit.page_titles?.missing?.count||0)+(audit.page_titles?.too_long?.count||0)+(audit.headings?.h1_missing?.count||0)+(audit.meta_descriptions?.missing?.count||0),'#8B5CF6')}\n  <div style=\"margin-bottom:28px;\">\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:10px;\">Page Titles</div>\n    <div style=\"display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;\">\n      ${[['Missing',audit.page_titles?.missing?.count||0,'#FEE2E2','#B91C1C'],['Too Long (>60 chars)',audit.page_titles?.too_long?.count||0,'#FEF3C7','#92400E'],['Duplicate',audit.page_titles?.duplicate?.count||0,'#FEF3C7','#92400E']].map(([lbl,val,bg,col])=>miniCard(lbl,num(val),'',bg,col)).join('')}\n    </div>\n    ${(audit.page_titles?.too_long?.rows||[]).length>0?`\n    <table>${tableHeader('URL','Title','Length')}<tbody>\n    ${(audit.page_titles.too_long.rows||[]).slice(0,8).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;max-width:230px;\">${safe(r.url||'')}</td><td style=\"padding:8px 14px;font-size:12px;color:#1E293B;\">${safe(r.title||'')}</td><td style=\"padding:8px 14px;\"><span style=\"font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:#FEF3C7;color:#92400E;\">${r.length||'?'} chars</span></td></tr>`).join('')}</tbody></table>`:''}\n  </div>\n  <div style=\"margin-bottom:28px;\">\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:10px;\">Meta Descriptions</div>\n    <div style=\"display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:14px;\">\n      ${[['Missing',audit.meta_descriptions?.missing?.count||0,'#FEF3C7','#92400E'],['Too Long (>155 chars)',audit.meta_descriptions?.too_long?.count||0,'#EFF6FF','#1E40AF'],['Too Short (<70 chars)',audit.meta_descriptions?.too_short?.count||0,'#EFF6FF','#1E40AF']].map(([lbl,val,bg,col])=>miniCard(lbl,num(val),'',bg,col)).join('')}\n    </div>\n    ${(audit.meta_descriptions?.missing?.rows||[]).length>0?`\n    <table>${tableHeader('Pages Missing Meta Description')}<tbody>\n    ${(audit.meta_descriptions.missing.rows||[]).slice(0,8).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;\">${safe(r.url||'')}</td></tr>`).join('')}\n    </tbody></table>`:''}\n  </div>\n  <div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:15px;font-weight:700;color:#0A1628;margin-bottom:10px;\">Heading Structure</div>\n    <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px;\">\n      ${[['H1 Missing',audit.headings?.h1_missing?.count||0,'#FEE2E2','#B91C1C'],['H1 Duplicate',audit.headings?.h1_duplicate?.count||0,'#FEF3C7','#92400E'],['H1 Multiple',audit.headings?.h1_multiple?.count||0,'#FEF3C7','#92400E'],['H2 Missing',audit.headings?.h2_missing?.count||0,'#EFF6FF','#1E40AF']].map(([lbl,val,bg,col])=>miniCard(lbl,num(val),'',bg,col)).join('')}\n    </div>\n    ${(audit.headings?.h1_multiple?.template_h1||[]).length>0?`\n    <div style=\"background:#FFFBEB;border:1px solid #FDE68A;border-radius:10px;padding:14px 18px;margin-top:10px;\">\n      <div style=\"font-size:11px;font-weight:700;color:#92400E;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:8px;\">\u26a0\ufe0f Sitewide Template H1 Detected \u2014 One Code Fix Resolves All</div>\n      <div style=\"font-size:13px;color:#1E293B;margin-bottom:10px;\">This H1 text appears on <strong>${num(audit.headings.h1_multiple.count)}</strong> pages \u2014 it is in your theme template. Changing it once in the template fixes all pages:</div>\n      ${(audit.headings.h1_multiple.template_h1||[]).map(t=>`<div style=\"display:flex;align-items:center;gap:10px;margin-bottom:6px;\"><span style=\"font-family:monospace;font-size:13px;background:#FEF9C3;padding:4px 10px;border-radius:6px;color:#92400E;\">\"${safe(t.text)}\"</span><span style=\"font-size:12px;color:#64748B;\">appears on ${num(t.count)} pages</span></div>`).join('')}\n    </div>`:''}\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 6: CONTENT QUALITY & INDEXATION \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udcc4','Content Quality & Indexation',null,'#6366F1')}\n  <div style=\"display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:22px;\">\n    ${[['Exact Duplicates',audit.content?.exact_duplicates?.count||0,'#FEE2E2','#B91C1C'],['Near Duplicates',audit.content?.near_duplicates?.count||0,'#FEF3C7','#92400E'],['Thin (<300 words)',audit.content?.thin_content_300?.count||audit.content?.low_content?.count||0,'#EFF6FF','#1E40AF'],['Noindex Directives',audit.directives?.noindex?.count||0,'#F5F3FF','#5B21B6']].map(([lbl,val,bg,col])=>miniCard(lbl,num(val),'',bg,col)).join('')}\n  </div>\n  <div style=\"margin-bottom:22px;\">\n    <div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:12px;\">Word Count Distribution</div>\n    ${buildWordCountBars()}\n  </div>\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;\">\n    <div style=\"padding:16px 20px;background:#FFFBEB;border-radius:10px;border:1px solid #FDE68A;\">\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#92400E;margin-bottom:5px;\">Oversized Images (&gt;100KB)</div>\n      <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:#92400E;\">${num(audit.images?.oversized?.count||0)}</div>\n      <div style=\"font-size:12px;color:#92400E;opacity:0.7;margin-top:3px;\">Primary cause of slow LCP</div>\n    </div>\n    <div style=\"padding:16px 20px;background:#FEF2F2;border-radius:10px;border:1px solid #FECACA;\">\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#B91C1C;margin-bottom:5px;\">Images Missing Alt Text</div>\n      <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:#B91C1C;\">${num(audit.images?.missing_alt?.count||0)}</div>\n      <div style=\"font-size:12px;color:#B91C1C;opacity:0.7;margin-top:3px;\">Invisible to Google Image Search</div>\n    </div>\n  </div>\n  ${(audit.images?.oversized?.rows||[]).length>0?`\n  <table style=\"margin-bottom:18px;\">${tableHeader('Image URL','Size (KB)','Pages Using It')}<tbody>\n  ${(audit.images.oversized.rows||[]).slice(0,8).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#1D4ED8;word-break:break-all;max-width:340px;\">${safe(r.url||'')}</td><td style=\"padding:8px 14px;\"><span style=\"font-size:11px;font-weight:600;padding:2px 9px;border-radius:99px;background:${(r.size_kb||0)>300?'#FEE2E2':(r.size_kb||0)>150?'#FEF3C7':'#FEF9C3'};color:${(r.size_kb||0)>300?'#B91C1C':(r.size_kb||0)>150?'#92400E':'#713F12'};\">${r.size_kb||0} KB</span></td><td style=\"padding:8px 14px;font-size:13px;color:#64748B;\">${num(r.inlinks||0)} pages</td></tr>`).join('')}\n  </tbody></table>`:''}\n  ${(audit.directives?.noindex?.suspicious_noindex||[]).length>0?`\n  <div style=\"background:#FEF2F2;border:1px solid #FECACA;border-radius:10px;padding:16px 20px;\">\n    <div style=\"font-size:11px;font-weight:700;color:#B91C1C;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:8px;\">\u26a0\ufe0f Potentially Accidental Noindex Pages</div>\n    <div style=\"font-size:13px;color:#1E293B;margin-bottom:10px;\">These pages have noindex set but don't appear to be system/admin pages \u2014 verify they are intentionally hidden:</div>\n    ${(audit.directives.noindex.suspicious_noindex||[]).slice(0,6).map(r=>`<div style=\"font-family:monospace;font-size:12px;color:#1D4ED8;margin-bottom:6px;padding:6px 10px;background:white;border-radius:6px;\">${safe(r.url||'')}</div>`).join('')}\n  </div>`:''}\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 7: SITE ARCHITECTURE \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\uddfa\ufe0f','Site Architecture & Internal Linking',null,'#2563EB')}\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:22px;\">\n    <div>\n      <div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:12px;\">Crawl Depth Distribution</div>\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:12px;padding:16px 20px;\">\n        ${buildDepthBars()}\n        <div style=\"margin-top:10px;font-size:12px;color:#64748B;line-height:1.6;\">Pages at depth 4+ are harder for Google to discover and receive less link equity. Aim to keep important pages within 3 clicks of the homepage.</div>\n      </div>\n    </div>\n    <div>\n      <div style=\"font-size:13px;font-weight:600;color:#0A1628;margin-bottom:12px;\">Orphan Pages (${num(audit.orphan_pages?.count||0)} found)</div>\n      <div style=\"background:#F8FAFC;border:0.5px solid #E2E8F0;border-radius:12px;padding:16px 20px;\">\n        ${audit.orphan_pages?.count>0?`\n        <div style=\"font-size:12px;color:#64748B;line-height:1.7;margin-bottom:10px;\">These indexable pages have <strong>zero internal links</strong> pointing to them. Google discovers pages mainly through links \u2014 orphan pages may not be crawled regularly and receive no PageRank.</div>\n        ${(audit.orphan_pages?.rows||[]).slice(0,3).map(r=>`<div style=\"font-family:monospace;font-size:11px;color:#1D4ED8;margin-bottom:5px;padding:5px 8px;background:white;border-radius:5px;word-break:break-all;\">${safe(r.url||'')}</div>`).join('')}\n        ${(audit.orphan_pages?.count||0)>3?`<div style=\"font-size:11px;color:#94A3B8;margin-top:6px;\">+${(audit.orphan_pages?.count||0)-3} more pages found</div>`:''}`\n        :'<div style=\"font-size:13px;color:#10B981;padding:10px 0;\">\u2705 No orphan pages detected. Good internal linking structure.</div>'}\n      </div>\n    </div>\n  </div>\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:12px;\">\n    <div style=\"padding:16px 20px;background:#F8FAFC;border-radius:10px;border:0.5px solid #E2E8F0;\">\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#94A3B8;margin-bottom:8px;\">HTTP Protocol Version</div>\n      <div style=\"display:flex;gap:20px;\">\n        ${[['HTTP/1.1',audit.meta.http_version?.http1||0,'#EF4444'],['HTTP/2',audit.meta.http_version?.http2||0,'#10B981']].map(([label,val,col])=>`<div><div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:22px;font-weight:800;color:${col};\">${num(val)}</div><div style=\"font-size:11px;color:#64748B;\">${label}</div></div>`).join('')}\n      </div>\n      ${(audit.meta.http_version?.http2||0)<(audit.meta.http_version?.http1||0)?`<div style=\"margin-top:10px;font-size:12px;color:#92400E;background:#FEF3C7;padding:8px;border-radius:6px;\">Upgrade to HTTP/2 for faster parallel loading</div>`:''}\n    </div>\n    <div style=\"padding:16px 20px;background:#F8FAFC;border-radius:10px;border:0.5px solid #E2E8F0;\">\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#94A3B8;margin-bottom:8px;\">Robots.txt Blocked Pages</div>\n      <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:${(audit.robots_analysis?.count||0)>20?'#F59E0B':'#10B981'};\">${num(audit.robots_analysis?.count||0)}</div>\n      ${(audit.robots_analysis?.rule_summary||[]).slice(0,2).map(r=>`<div style=\"font-size:11px;color:#64748B;margin-top:4px;\">Rule: <span style=\"font-family:monospace;font-size:10px;\">${safe(r.rule)}</span> (${r.count} pages)</div>`).join('')}\n    </div>\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 8: SECURITY (only shown if issues found) \u2550\u2550\u2550 -->\n${((audit.security?.http_pages?.count||0)+(audit.security?.mixed_content?.count||0))>0?`\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udd12','Security Issues',(audit.security?.http_pages?.count||0)+(audit.security?.mixed_content?.count||0),'#EF4444')}\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:22px;\">\n    ${[['HTTP (Insecure) Pages',audit.security?.http_pages?.count||0,'Chrome shows \"Not Secure\" \u2014 ranking penalty'],['Mixed Content Pages',audit.security?.mixed_content?.count||0,'HTTP resources on HTTPS pages']].map(([lbl,val,sub])=>`\n    <div style=\"padding:18px 22px;background:#FEF2F2;border-radius:11px;border:1px solid #FECACA;\">\n      <div style=\"font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.7px;color:#B91C1C;margin-bottom:5px;\">${lbl}</div>\n      <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:28px;font-weight:800;color:#B91C1C;\">${num(val)}</div>\n      <div style=\"font-size:12px;color:#B91C1C;opacity:0.7;margin-top:4px;\">${sub}</div>\n    </div>`).join('')}\n  </div>\n  ${(audit.security?.http_pages?.rows||[]).length>0?`\n  <table>${tableHeader('HTTP URL','Status Code','Action Required')}<tbody>\n  ${(audit.security.http_pages.rows||[]).slice(0,8).map((r,i)=>`<tr style=\"border-bottom:0.5px solid #E2E8F0;${i%2===1?'background:#F8FAFC;':''}\"><td style=\"padding:8px 14px;font-family:monospace;font-size:11px;color:#DC2626;word-break:break-all;\">${safe(r.url||'')}</td><td style=\"padding:8px 14px;\"><span style=\"font-size:10px;font-weight:600;padding:2px 8px;border-radius:99px;background:#FEE2E2;color:#B91C1C;\">${safe(r.status_code||'')}</span></td><td style=\"padding:8px 14px;font-size:12px;color:#64748B;\">Set up 301 redirect to HTTPS version</td></tr>`).join('')}\n  </tbody></table>`:''}\n</div>`:''}\n\n<!-- \u2550\u2550\u2550 SECTION 9: ISSUE EXPLANATIONS \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udcd6','Issue Explanations \u2014 What Each Means & How To Fix It',null,'#6366F1')}\n  ${buildIssueCards()}\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 10: COMPLETE PRIORITY ACTION PLAN \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83c\udfaf','Complete Priority Action Plan',null,'#EF4444')}\n  <table>${tableHeader('#','Action','Scale','Effort','Priority')}<tbody>${buildPriorityRows()}</tbody></table>\n  <div style=\"margin-top:16px;padding:14px 18px;background:#F8FAFC;border-radius:10px;border:0.5px solid #E2E8F0;\">\n    <div style=\"font-size:11px;font-weight:600;color:#64748B;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.7px;\">Effort Key</div>\n    <div style=\"display:flex;gap:24px;\">\n      ${[['Low','1 bar \u2014 can be done in hours'],['Medium','2 bars \u2014 needs a day or two'],['High','3 bars \u2014 complex implementation']].map(([lbl,desc])=>`<div style=\"font-size:12px;color:#64748B;\"><strong style=\"color:#0A1628;\">${lbl} effort</strong> \u2014 ${desc}</div>`).join('')}\n    </div>\n  </div>\n</div>\n\n<!-- \u2550\u2550\u2550 SECTION 11: ABBREVIATIONS & GLOSSARY \u2550\u2550\u2550 -->\n<div class=\"section\">\n  ${sectionHeader('\ud83d\udcd6','Abbreviations & Glossary',null,'#64748B')}\n  <div style=\"font-size:13px;color:#475569;margin-bottom:18px;line-height:1.7;\">Reference guide to all technical terms and abbreviations used in this report.</div>\n  <div style=\"display:grid;grid-template-columns:1fr 1fr;border:0.5px solid #E2E8F0;border-radius:12px;overflow:hidden;\">\n    ${glossaryItems.map(([abbr,full,desc],i)=>`\n      <div style=\"padding:14px 18px;border-bottom:0.5px solid #F1F5F9;${i%2===0?'border-right:0.5px solid #E2E8F0;':''}background:${Math.floor(i/2)%2===0?'white':'#FAFAFA'};\">\n        <div style=\"display:flex;align-items:baseline;gap:10px;margin-bottom:5px;\">\n          <span style=\"font-family:'DM Mono',monospace;font-size:11px;font-weight:600;color:white;background:#0A1628;padding:2px 9px;border-radius:5px;white-space:nowrap;\">${safe(abbr)}</span>\n          <span style=\"font-size:12.5px;font-weight:700;color:#1E293B;\">${safe(full)}</span>\n        </div>\n        <div style=\"font-size:11.5px;color:#64748B;line-height:1.65;\">${safe(desc)}</div>\n      </div>`).join('')}\n  </div>\n</div>\n\n<!-- FOOTER -->\n<div style=\"background:#0A1628;padding:24px 44px;display:flex;align-items:center;justify-content:space-between;\">\n  <div>\n    <div style=\"font-family:'Bricolage Grotesque',sans-serif;font-size:14px;font-weight:700;color:rgba(255,255,255,0.5);\">Generated by <span style=\"color:white;\">${safe(AGENCY.name)}</span></div>\n    <div style=\"font-size:11px;color:rgba(255,255,255,0.3);margin-top:3px;\">Confidential \u2014 Prepared for ${safe(domain)}</div>\n  </div>\n  <div style=\"text-align:center;\">\n    <div style=\"font-size:10px;color:rgba(255,255,255,0.25);text-transform:uppercase;letter-spacing:1px;\">Audit Date</div>\n    <div style=\"font-size:13px;color:rgba(255,255,255,0.5);margin-top:2px;\">${audit.meta.crawl_date}</div>\n  </div>\n  <div style=\"text-align:right;font-size:12px;color:rgba(255,255,255,0.4);line-height:1.9;\">\n    <a href=\"mailto:${safe(AGENCY.email)}\" style=\"color:rgba(255,255,255,0.65);text-decoration:none;\">${safe(AGENCY.email)}</a><br/>\n    <span>${safe(AGENCY.website)}</span>\n  </div>\n</div>\n\n</div>\n</body>\n</html>`;\n\nreturn [{ json: { html, website: audit.meta.website, score: audit.health.score, domain } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "66b72f96-ff24-41e2-9fa1-8e9463e2959e",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1376,
        4032
      ],
      "parameters": {
        "width": 576,
        "height": 736,
        "content": "## \ud83d\ude80 Enterprise Technical SEO Audit Engine\n\n### How it works\n1. **Trigger:** Listens for a Slack message containing `#Audit [URL]`.\n2. **Crawl:** Pings a custom Screaming Frog API to crawl the site headlessly and loops until finished.\n3. **Data Enrichment:** Downloads the crawl CSVs, unzips them, and fetches Google PageSpeed Insights (PSI) data for the URL.\n4. **Report Generation:** Custom JS parsers build a branded, executive HTML/PDF report and a deeply categorized Excel Fixes sheet.\n5. **Delivery:** Uploads both files to Google Drive, logs the audit in Google Sheets, and sends the final links back to Slack!\n\n### \u26a0\ufe0f CRITICAL SETUP STEPS \u26a0\ufe0f\n- [ ] **Slack Trigger:** Re-authenticate your Slack App and ensure the channel ID is correct.\n- [ ] **SF API / Crawler:** Ensure the `Start Crawl` and `Check Crawl` nodes have the correct Bearer Token for your Python FastAPI server.\n- [ ] **PageSpeed Insights:** \u26a0\ufe0f Open the `Page Speed Insights` HTTP node and replace the hardcoded API `key` with your own Google Cloud API Key.\n- [ ] **PDF Generator:** \u26a0\ufe0f Open `HTML TO PDF` and add your PDFEndpoint API key in the Headers.\n- [ ] **Agency Branding:** \u26a0\ufe0f Open the `Report Builder` node. Find `const AGENCY = { ... }` around line 125 and update your name, email, and tagline.\n- [ ] **Storage & Logging:** Update the **Folder ID** in the Google Drive nodes and the **Document ID** in the Google Sheets node."
      },
      "typeVersion": 1
    },
    {
      "id": "dd719127-ae82-4821-a1d5-2053bee53f56",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -752,
        4352
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 368,
        "content": "## 1. \ud83d\udcac Slack Trigger & Input\nListens for the `#Audit` command in Slack, extracts the raw URL using RegEx, and prepares the metadata (timestamp, channel ID, watermark) for the rest of the workflow.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\nEnsure your Slack API credentials are authenticated and the channel ID (`D0ATVN7D5B4`) is correct for your workspace.\n\nAlso Set Watermark in Input Cleaner"
      },
      "typeVersion": 1
    },
    {
      "id": "0681b661-8826-448a-9f39-491e70d240d2",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        4240
      ],
      "parameters": {
        "color": 7,
        "width": 1136,
        "height": 576,
        "content": "## 2. \ud83d\udd77\ufe0f Screaming Frog Crawl Loop\nTriggers the custom Python API to run Screaming Frog. Uses a polling loop (Check -> Switch -> Wait -> Check) to monitor the crawl status until it returns `done`, `timeout`, or `failed`.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\nIf you change your FastAPI server password, update the `Authorization: Bearer` header in both the **Start Crawl** and **Check Crawl** nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "a1f890d2-2c39-4cd3-9423-20a363469c39",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1280,
        4208
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 352,
        "content": "## 3. \ud83d\udce5 Fetch & Unpack\nOnce the API loop finishes, this downloads the exported `.zip` file from your server and decompresses the raw CSVs into binary data for the parsers.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\n\nAdd the Token In fetch crawl Node\n"
      },
      "typeVersion": 1
    },
    {
      "id": "d7b36bad-5039-4c6c-97a2-cca6af0a936d",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1888,
        4016
      ],
      "parameters": {
        "color": 7,
        "width": 784,
        "height": 672,
        "content": "## 4. \ud83e\udde0 Parsing & PageSpeed Insights\nThe heaviest logic in the workflow. \n1. Parses massive Screaming Frog CSVs into JSON arrays.\n2. Fetches real-time Lab/Field data from Google PageSpeed Insights.\n3. Merges the CWV data with the SF crawl data into a single master payload.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\nOpen the **Page Speed Insights** node. In the Query Parameters, replace the \n[Your-psi-api-key] with your own active Google API Key."
      },
      "typeVersion": 1
    },
    {
      "id": "4aadaffe-4813-49b2-8909-d54844b9c983",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2704,
        4016
      ],
      "parameters": {
        "color": 7,
        "width": 784,
        "height": 672,
        "content": "## 5. \ud83d\udcca Excel & PDF Generation\nUses the master JSON to dynamically build two assets:\n* **Excel Builder:** Creates a multi-tab `.xls` file with color-coded severity rows.\n* **HTML/PDF Builder:** Injects data into a beautiful, branded HTML template and converts it to a PDF.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\n1. Open **Report Builder** (Code Node) -> Update the `AGENCY` constant with your details Like Agency Name, Email, Website.\n2. Open **HTML TO PDF** -> Update the Bearer token in the header with your `pdfendpoint.com` API key."
      },
      "typeVersion": 1
    },
    {
      "id": "e94084d6-2669-4526-b3a1-6f7a7445af56",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3504,
        4016
      ],
      "parameters": {
        "color": 7,
        "width": 928,
        "height": 672,
        "content": "## 6. \u2601\ufe0f GDrive, Sheets & Slack Delivery\nUploads the generated Excel and PDF files to Google Drive, grabs the public `webViewLinks`, logs the run in a Google Sheet for tracking, and pings the final URLs back to the original Slack thread.\n\n\u26a0\ufe0f **ACTION REQUIRED:**\n1. Open both **Google Drive** nodes and map them to your specific `SEO Audits` Folder ID.\n2. Open the **Google Sheets** node and select your specific tracking document."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "Extract URL",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Data": {
      "main": [
        [
          {
            "node": "HTML TO PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait": {
      "main": [
        [
          {
            "node": "Check Crawl",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch": {
      "main": [
        [
          {
            "node": "Compression",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Aggregate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait1": {
      "main": [
        [
          {
            "node": "Check Crawl",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch": {
      "main": [
        [
          {
            "node": "Arrange Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Arrange Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Failed Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate": {
      "main": [
        [
          {
            "node": "Append row in sheet",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PSI Parser": {
      "main": [
        [
          {
            "node": "Report Builder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Crawl": {
      "main": [
        [
          {
            "node": "Switch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compression": {
      "main": [
        [
          {
            "node": "SEO Audit Parser",
            "type": "main",
            "index": 0
          },
          {
            "node": "Full Data Parser",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract URL": {
      "main": [
        [
          {
            "node": "Input Cleaner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTML TO PDF": {
      "main": [
        [
          {
            "node": "Downlaod PDF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start Crawl": {
      "main": [
        [
          {
            "node": "Wait1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tab Builder": {
      "main": [
        [
          {
            "node": "Excel File Builder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload file": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Arrange Data": {
      "main": [
        [
          {
            "node": "Fetch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Downlaod PDF": {
      "main": [
        [
          {
            "node": "Upload to Drive",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Input Cleaner": {
      "main": [
        [
          {
            "node": "Start Crawl",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Trigger": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Report Builder": {
      "main": [
        [
          {
            "node": "Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to Drive": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Full Data Parser": {
      "main": [
        [
          {
            "node": "Tab Builder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SEO Audit Parser": {
      "main": [
        [
          {
            "node": "Page Speed Insights",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Excel File Builder": {
      "main": [
        [
          {
            "node": "Upload file",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Page Speed Insights": {
      "main": [
        [
          {
            "node": "PSI Parser",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}