{
  "name": "Startseiten-Anfragecheck - Live V1",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "audit",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -420,
        -360
      ],
      "id": "eec8d340-6935-4c25-8403-399424ed6a58",
      "name": "Webhook"
    },
    {
      "parameters": {
        "jsCode": "const req = $input.first().json || {};\nconst body = req.body || {};\nconst ui = body.__submission?.user_inputs || body;\nlet url = (ui.url || body.url || '').toString().trim();\nconst ip = (body.__submission?.ip || req.ip || req.headers?.['x-forwarded-for'] || '').toString();\nconst ua = (body.__submission?.browser || req.headers?.['user-agent'] || '').toString();\nconst errors = [];\n\nfunction normalizeUrl(input, required) {\n  let value = (input || '').toString().trim();\n  if (!value) {\n    if (required) errors.push('Bitte eine Seiten-URL angeben.');\n    return '';\n  }\n  if (!/^https?:\\/\\//i.test(value)) value = 'https://' + value;\n  try {\n    const u = new URL(value);\n    if (!/^https?:$/.test(u.protocol)) {\n      errors.push('Nur http/https URLs sind erlaubt.');\n      return '';\n    }\n    u.hash = '';\n    return u.toString().replace(/\\/+$/, '');\n  } catch (e) {\n    errors.push('URL ist ungueltig. Beispiel: https://example.de');\n    return '';\n  }\n}\n\nfunction isPrivateIPv4(host) {\n  const m = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/);\n  if (!m) return false;\n  const a = m.slice(1).map((x) => parseInt(x, 10));\n  if (a.some((x) => Number.isNaN(x) || x < 0 || x > 255)) return true;\n  if (a[0] === 10) return true;\n  if (a[0] === 127) return true;\n  if (a[0] === 169 && a[1] === 254) return true;\n  if (a[0] === 192 && a[1] === 168) return true;\n  if (a[0] === 172 && a[1] >= 16 && a[1] <= 31) return true;\n  return false;\n}\n\nurl = normalizeUrl(url, true);\n\nif (errors.length === 0 && url) {\n  const host = new URL(url).hostname.toLowerCase();\n  const hostNoWww = host.replace(/^www\\./, '');\n  const denyHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '169.254.169.254'];\n  const isIp = /^[0-9.]+$/.test(hostNoWww) || hostNoWww.includes(':');\n\n  if (denyHosts.includes(hostNoWww)) errors.push('Diese URL ist nicht erlaubt.');\n  if (isIp && isPrivateIPv4(hostNoWww)) errors.push('Private oder interne IPs sind nicht erlaubt.');\n  if (/\\.local$|\\.internal$|\\.lan$|\\.home$/.test(hostNoWww)) errors.push('Interne Domains sind nicht erlaubt.');\n\n  try {\n    const sd = $getWorkflowStaticData('global');\n    const now = Date.now();\n    const hour = Math.floor(now / (60 * 60 * 1000));\n    const key = 'rl:' + (ip || 'unknown') + ':' + hour;\n    sd[key] = (sd[key] || 0) + 1;\n    if (sd[key] > 30) errors.push('Zu viele Anfragen. Bitte in 60 Minuten erneut versuchen.');\n  } catch (e) {}\n}\n\nconst jobId = 'job_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);\nlet domain = null;\nif (url) {\n  try {\n    domain = new URL(url).hostname.replace(/^www\\./, '');\n  } catch (e) {}\n}\n\nreturn [{\n  json: {\n    ok: errors.length === 0,\n    error: errors.length ? errors.join(' ') : null,\n    url,\n    domain,\n    jobId,\n    ip,\n    ua,\n    ts: new Date().toISOString(),\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -180,
        -360
      ],
      "id": "b373da95-246e-4b5a-9875-7a454051638a",
      "name": "URL Validator"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -20,
        -360
      ],
      "id": "9cd2f2b2-dab0-4012-aef4-5396eb5a18e2",
      "name": "IF Valid Request"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, status: $json.status || \"error\", error: $json.error || \"Ungueltige Anfrage\" } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        220,
        -220
      ],
      "id": "0837f051-6d95-4dd0-9cc9-ab0ff5ac8513",
      "name": "Respond Error"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: true, jobId: $json.jobId, status: \"processing\", message: \"Check gestartet. Die Seite wird jetzt gelesen und priorisiert. Das Ergebnis erscheint in ca. 20-40 Sekunden direkt hier.\", pollUrl: \"/webhook/audit-status?jobId=\" + $json.jobId } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        220,
        -500
      ],
      "id": "35455d39-85c5-4b12-a146-c30a60cacfae",
      "name": "Respond OK"
    },
    {
      "parameters": {
        "jsCode": "const sd = $getWorkflowStaticData('global');\nconst meta = $('URL Validator').first().json || {};\nconst now = Date.now();\nconst ttlMs = 2 * 60 * 60 * 1000;\n\nsd[meta.jobId] = JSON.stringify({\n  status: 'processing',\n  message: 'Check laeuft...',\n  request: {\n    url: meta.url,\n    domain: meta.domain || null,\n    ip: meta.ip || '',\n    ua: meta.ua || '',\n  },\n  storedAt: now,\n  updatedAt: now,\n  expiresAt: now + ttlMs,\n});\n\nconst hour = Math.floor(now / (60 * 60 * 1000));\nfor (const key of Object.keys(sd)) {\n  if (key.startsWith('job_')) {\n    try {\n      const entry = JSON.parse(sd[key]);\n      if (entry.expiresAt && entry.expiresAt < now) delete sd[key];\n    } catch (e) {\n      delete sd[key];\n    }\n    continue;\n  }\n\n  if (key.startsWith('rl:')) {\n    const keyHour = parseInt(key.split(':')[2] || '0', 10);\n    if (Number.isFinite(keyHour) && keyHour < hour - 1) delete sd[key];\n  }\n}\n\nreturn [{ json: meta }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        420,
        -500
      ],
      "id": "87771a69-54fd-493f-a895-02508ab7d0c3",
      "name": "Store Processing"
    },
    {
      "parameters": {
        "url": "=https://r.jina.ai/{{ $json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (compatible; AuditBot/1.0)"
            }
          ]
        },
        "options": {
          "allowUnauthorizedCerts": false,
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 20000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        100,
        -360
      ],
      "id": "bf5ac95a-39c3-4f11-b3da-6d1f5fc69218",
      "name": "Jina Reader",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "={{ $(\"URL Validator\").first().json.url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (compatible; AuditBot/1.0)"
            },
            {
              "name": "Accept",
              "value": "text/html"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        420,
        -340
      ],
      "id": "4a4ff5b8-e4f5-4616-8d29-c41476b7974e",
      "name": "Raw HTML Fetch",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=performance&category=accessibility&category=best-practices",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $(\"URL Validator\").first().json.url }}"
            },
            {
              "name": "strategy",
              "value": "mobile"
            },
            {
              "name": "category",
              "value": "performance"
            },
            {
              "name": "category",
              "value": "accessibility"
            },
            {
              "name": "category",
              "value": "best-practices"
            },
            {
              "name": "locale",
              "value": "de_DE"
            },
            {
              "name": "key",
              "value": "={{ $vars.GOOGLE_PAGESPEED_API_KEY || $env.GOOGLE_PAGESPEED_API_KEY }}"
            }
          ]
        },
        "options": {
          "timeout": 120000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        620,
        -420
      ],
      "id": "d13d3b5d-0120-4af7-b165-c5b653239726",
      "name": "PageSpeed API",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "function unwrap(x) {\n  if (!x) return {};\n  if (x.lighthouseResult || x.error) return x;\n  if (x.body && (x.body.lighthouseResult || x.body.error)) return x.body;\n  if (x.data && (x.data.lighthouseResult || x.data.error)) return x.data;\n  if (x.response && (x.response.lighthouseResult || x.response.error)) return x.response;\n  if (x.response?.body && (x.response.body.lighthouseResult || x.response.body.error)) return x.response.body;\n  return x;\n}\n\nconst p = unwrap($input.first().json || {});\nconst needRetry = (!p.lighthouseResult) || !!p.error;\nreturn [{ json: { needRetry } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        -420
      ],
      "id": "e6e90a7c-196f-4ed1-9ee8-e08fba671917",
      "name": "Check PageSpeed"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.needRetry }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        940,
        -420
      ],
      "id": "117094b2-f825-41d9-ac29-6eb1f2ac805c",
      "name": "IF PageSpeed Retry?"
    },
    {
      "parameters": {
        "amount": 12,
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        1100,
        -240
      ],
      "id": "76928715-a72f-4c24-ab6c-9d653cfc220c",
      "name": "Wait 12s"
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=performance&category=accessibility&category=best-practices",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $(\"URL Validator\").first().json.url }}"
            },
            {
              "name": "strategy",
              "value": "mobile"
            },
            {
              "name": "category",
              "value": "performance"
            },
            {
              "name": "category",
              "value": "accessibility"
            },
            {
              "name": "category",
              "value": "best-practices"
            },
            {
              "name": "locale",
              "value": "de_DE"
            },
            {
              "name": "key",
              "value": "={{ $vars.GOOGLE_PAGESPEED_API_KEY || $env.GOOGLE_PAGESPEED_API_KEY }}"
            }
          ]
        },
        "options": {
          "timeout": 120000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1260,
        -240
      ],
      "id": "7454108e-fa98-40e4-a4f2-71fcbab9652a",
      "name": "PageSpeed API Retry",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "function unwrap(x) {\n  if (!x) return {};\n  if (x.lighthouseResult || x.error) return x;\n  if (x.body && (x.body.lighthouseResult || x.body.error)) return x.body;\n  if (x.data && (x.data.lighthouseResult || x.data.error)) return x.data;\n  if (x.response && (x.response.lighthouseResult || x.response.error)) return x.response;\n  if (x.response?.body && (x.response.body.lighthouseResult || x.response.body.error)) return x.response.body;\n  return x;\n}\n\nfunction perfScore(obj) {\n  const s = obj?.lighthouseResult?.categories?.performance?.score;\n  return typeof s === 'number' ? Math.round(s * 100) : 0;\n}\n\nfunction ok(obj) {\n  return !!obj?.lighthouseResult && !obj?.error;\n}\n\nlet p1 = {};\nlet p2 = {};\ntry { p1 = unwrap($('PageSpeed API').first().json); } catch (e) {}\ntry { p2 = unwrap($('PageSpeed API Retry').first().json); } catch (e) {}\n\nlet chosen = p1;\nif (ok(p2) && (!ok(p1) || perfScore(p2) >= perfScore(p1))) chosen = p2;\nchosen = chosen || {};\n\nchosen._auditMeta = chosen._auditMeta || {};\nchosen._auditMeta.pagespeed = {\n  chosen: ok(chosen) ? (chosen === p2 ? 'retry' : 'first') : 'none',\n  try1_ok: ok(p1),\n  try2_ok: ok(p2),\n  score_try1: perfScore(p1),\n  score_try2: perfScore(p2)\n};\n\nreturn [{ json: chosen }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1260,
        -420
      ],
      "id": "67c3ac61-098f-487c-8644-e0517b957822",
      "name": "PageSpeed Final"
    },
    {
      "parameters": {
        "jsCode": "const meta = $('URL Validator').first().json || {};\n\nfunction safeString(value) {\n  if (typeof value === 'string') return value;\n  if (value === null || value === undefined) return '';\n  try { return JSON.stringify(value); } catch (e) { return String(value); }\n}\n\nfunction toTextPayload(payload) {\n  if (!payload) return '';\n  if (typeof payload === 'string') return payload;\n  return safeString(payload.data || payload.body || payload.response?.body || payload.response || '');\n}\n\nfunction hasRequestError(payload) {\n  if (!payload || typeof payload !== 'object') return false;\n  if (payload.error) return true;\n  if (payload.statusCode && Number(payload.statusCode) >= 400) return true;\n  if (payload.status && Number(payload.status) >= 400) return true;\n  return false;\n}\n\nfunction stripHtml(html) {\n  return String(html || '')\n    .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n    .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n    .replace(/<noscript[\\s\\S]*?<\\/noscript>/gi, ' ')\n    .replace(/<svg[\\s\\S]*?<\\/svg>/gi, ' ')\n    .replace(/<[^>]+>/g, ' ');\n}\n\nfunction cleanText(text) {\n  return String(text || '').replace(/\\s+/g, ' ').trim();\n}\n\nfunction wordCount(text) {\n  return cleanText(text).split(/\\s+/).filter(Boolean).length;\n}\n\nfunction firstMatch(text, regex) {\n  const match = String(text || '').match(regex);\n  return match ? cleanText(match[1] || match[0]) : '';\n}\n\nfunction truncate(text, max) {\n  const value = cleanText(text);\n  if (value.length <= max) return value;\n  return value.slice(0, Math.max(0, max - 1)).trim() + '\u2026';\n}\n\nfunction boolLabel(value, positive, negative) {\n  return value ? positive : negative;\n}\n\nfunction boolStatus(value, negativeStatus) {\n  return value ? 'good' : (negativeStatus || 'bad');\n}\n\nlet html = '';\nlet rawHtmlOk = false;\nlet rawHtmlStatus = 'unknown';\ntry {\n  const rn = $('Raw HTML Fetch').first().json || {};\n  rawHtmlStatus = String(rn.statusCode || rn.status || 'ok');\n  html = toTextPayload(rn);\n  rawHtmlOk = !hasRequestError(rn) && html.length > 300;\n} catch (e) {\n  rawHtmlStatus = 'exception';\n}\n\nlet readerText = '';\nlet readerOk = false;\ntry {\n  const jn = $('Jina Reader').first().json || {};\n  readerText = cleanText(toTextPayload(jn));\n  readerOk = !hasRequestError(jn) && wordCount(readerText) > 80;\n} catch (e) {}\n\nconst strippedHtml = cleanText(stripHtml(html));\nconst combinedText = cleanText((readerOk ? readerText + ' ' : '') + strippedHtml);\nconst contentReliable = rawHtmlOk || readerOk || wordCount(strippedHtml) > 80;\nconst topText = truncate(combinedText.split(/\\s+/).slice(0, 140).join(' '), 600);\n\nconst headMatch = html.match(/<head[^>]*>([\\s\\S]*?)<\\/head>/i);\nconst head = headMatch ? headMatch[1] : html.slice(0, 8000);\nconst title = firstMatch(head, /<title[^>]*>([^<]{0,240})<\\/title>/i);\nconst metaDesc = firstMatch(head, /<meta[^>]*name=[\"']description[\"'][^>]*content=[\"']([^\"']{0,320})[\"']/i) || firstMatch(head, /<meta[^>]*content=[\"']([^\"']{0,320})[\"'][^>]*name=[\"']description[\"']/i);\nconst canonical = firstMatch(head, /<link[^>]*rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)[\"']/i);\nconst h1Matches = [...html.matchAll(/<h1[^>]*>([\\s\\S]*?)<\\/h1>/gi)].map((match) => cleanText(stripHtml(match[1]))).filter(Boolean);\nconst h1 = h1Matches[0] || '';\nconst h1WordCount = wordCount(h1);\nconst titleWordCount = wordCount(title);\n\nconst htmlLinks = [...html.matchAll(/<a[^>]+href=[\"']([^\"']+)[\"']/gi)].map((match) => String(match[1] || '').trim()).filter(Boolean);\nconst bodyLower = combinedText.toLowerCase();\nconst firstScreenLower = topText.toLowerCase();\n\nconst hasOutcomeHint = /(anfragen|leads|kunden|termine|bewerber|umsatz|vertrauen|sichtbarkeit|wachstum|conversion|mehr|weniger|schneller|effizient)/i.test((h1 + ' ' + title + ' ' + topText));\nconst hasAudienceHint = /(fuer|fuer\b|b2b|unternehmen|teams|agenturen|dienstleister|kanzleien|aerzte|praxen|saas|shops|marken)/i.test((h1 + ' ' + title + ' ' + topText));\nconst hasSpecificityHint = /(\\b\\d+\\b|tage|wochen|monate|schritte|cases|kunden|projekten|projekten|projekte|minutes|minuten)/i.test((h1 + ' ' + title + ' ' + topText));\nconst titleMatchesH1 = !!(title && h1) && (title.toLowerCase().includes(h1.toLowerCase().slice(0, 20)) || h1.toLowerCase().includes(title.toLowerCase().slice(0, 20)));\n\nconst hasCases = /case.?stud|referenz|kundenprojekt|erfolgsgeschichte|portfolio|projektbeispiel/i.test(combinedText + ' ' + html);\nconst hasTestimonials = /testimonial|kundenstimm|bewertung|rezension|review|stimmen unserer kunden|das sagen kunden/i.test(combinedText + ' ' + html);\nconst hasStats = /\\b\\d+\\+?\\s*(kunden|projekte|jahre|bewertungen|reviews|mio|million|leads|anfragen|umsatz|downloads)\\b/i.test(combinedText);\nconst hasLogos = /logos|bekannt aus|vertrauen auf|arbeitet fuer|partner/i.test(combinedText + ' ' + html);\n\nconst hasForm = /<form[\\s>]/i.test(html) || /kontaktformular|anfrageformular|formular/i.test(combinedText);\nconst hasBookingLink = htmlLinks.some((link) => /cal\\.com|calendly|youcanbook|bookeo|book|buchen|termin|demo|strategiegespraech|beratungsgespraech/i.test(link)) || /termin buchen|demo buchen|gespraech buchen|beratungsgespraech|strategiegespraech/i.test(combinedText);\nconst hasEmail = /mailto:|[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i.test(html + ' ' + combinedText);\nconst hasPhone = /(\\+?\\d{2,4}[\\s./-]?\\d{2,}[\\s./-]?\\d{2,})/.test(combinedText);\nconst hasCtaAboveFold = /(kontakt|anfrage|termin|demo|gespraech|beratung|starten|buchen|angebot|anrufen)/i.test(firstScreenLower);\nconst isHttps = String(meta.url || '').startsWith('https://');\n\nlet mobileScore = 0;\nlet lcp = 'n/a';\nlet lcpMs = 0;\nlet cls = 'n/a';\nlet clsVal = 0;\nlet tbt = 'n/a';\nlet tbtMs = 0;\nlet pagespeedOk = false;\ntry {\n  const gd = $('PageSpeed Final').first().json || {};\n  if (gd.lighthouseResult) {\n    const lh = gd.lighthouseResult;\n    const audits = lh.audits || {};\n    pagespeedOk = true;\n    mobileScore = typeof lh.categories?.performance?.score === 'number' ? Math.round(lh.categories.performance.score * 100) : 0;\n    lcp = audits['largest-contentful-paint']?.displayValue || 'n/a';\n    lcpMs = audits['largest-contentful-paint']?.numericValue || 0;\n    cls = audits['cumulative-layout-shift']?.displayValue || 'n/a';\n    clsVal = audits['cumulative-layout-shift']?.numericValue || 0;\n    tbt = audits['total-blocking-time']?.displayValue || 'n/a';\n    tbtMs = audits['total-blocking-time']?.numericValue || 0;\n  }\n} catch (e) {}\n\nlet promiseScore = 0;\nif (h1) promiseScore += 1;\nif (title) promiseScore += 1;\nif (metaDesc) promiseScore += 1;\nif (hasOutcomeHint) promiseScore += 1;\nif (hasAudienceHint) promiseScore += 1;\nif (hasSpecificityHint) promiseScore += 1;\nif (titleMatchesH1) promiseScore += 1;\nif (h1WordCount >= 4 && h1WordCount <= 16) promiseScore += 1;\n\nlet promiseStatus = 'warning';\nlet promiseValue = 'teilweise lesbar';\nlet promiseHelp = 'Die Seite war heute nicht vollstaendig auslesbar.';\nlet promiseSummary = 'Die Seite konnte nicht komplett gelesen werden. Aussagen zum Seitenversprechen bleiben deshalb vorsichtig.';\nif (contentReliable) {\n  if (promiseScore >= 6) {\n    promiseStatus = 'good';\n    promiseValue = 'scharf';\n    promiseHelp = 'H1, Title und Nutzenrichtung sind im ersten Eindruck gut erkennbar.';\n    promiseSummary = 'Das Seitenversprechen ist im ersten Eindruck greifbar. Besucher verstehen relativ schnell, worum es geht.';\n  } else if (promiseScore >= 3) {\n    promiseStatus = 'warning';\n    promiseValue = 'verbesserbar';\n    promiseHelp = 'Eine Richtung ist erkennbar, aber noch nicht spitz genug.';\n    promiseSummary = 'Die Seite sagt schon etwas, aber noch nicht klar genug fuer wen sie gedacht ist und warum man hier bleiben sollte.';\n  } else {\n    promiseStatus = 'bad';\n    promiseValue = 'zu diffus';\n    promiseHelp = 'Zu wenig Klarheit im ersten Eindruck.';\n    promiseSummary = 'Der erste Eindruck bleibt zu allgemein. Besucher muessen zu viel selbst zusammensetzen, bevor Vertrauen entstehen kann.';\n  }\n}\n\nconst proofScore = [hasCases, hasTestimonials, hasStats, hasLogos].filter(Boolean).length;\nlet proofStatus = 'warning';\nlet proofValue = 'teilweise lesbar';\nlet proofHelp = 'Proof-Signale sind heute nicht vollstaendig lesbar.';\nlet proofSummary = 'Die Seite konnte inhaltlich nur teilweise gelesen werden. Aussagen zu Referenzen und Beweis bleiben vorsichtig.';\nif (contentReliable) {\n  if (proofScore >= 3) {\n    proofStatus = 'good';\n    proofValue = 'stark';\n    proofHelp = 'Mehrere sichtbare Proof-Signale wurden erkannt.';\n    proofSummary = 'Die Seite liefert bereits sichtbaren Beweis. Das staerkt Vertrauen frueh genug im Besuch.';\n  } else if (proofScore >= 1) {\n    proofStatus = 'warning';\n    proofValue = 'duenn';\n    proofHelp = 'Einzelne Proof-Signale sind da, aber noch nicht dicht genug.';\n    proofSummary = 'Es gibt erste Vertrauenssignale, aber noch nicht genug Beweis genau dort, wo die Anfrage entschieden wird.';\n  } else {\n    proofStatus = 'bad';\n    proofValue = 'fehlt fast ganz';\n    proofHelp = 'Kaum sichtbarer Beweis fuer Vertrauen.';\n    proofSummary = 'Die Seite zeigt zu wenig Beweis dafuer, warum ein Besucher ausgerechnet hier anfragen sollte.';\n  }\n}\n\nlet nextStepStatus = 'warning';\nlet nextStepValue = 'teilweise lesbar';\nlet nextStepHelp = 'Der Conversion-Pfad ist heute nicht vollstaendig lesbar.';\nlet nextStepSummary = 'Die Seite konnte inhaltlich nur teilweise gelesen werden. Aussagen zum naechsten Schritt bleiben deshalb vorsichtig.';\nif (contentReliable) {\n  if ((hasForm || hasBookingLink) && hasCtaAboveFold) {\n    nextStepStatus = 'good';\n    nextStepValue = 'klar';\n    nextStepHelp = 'Es gibt einen sichtbaren und gut anschlussfaehigen naechsten Schritt.';\n    nextStepSummary = 'Ein Besucher findet einen klaren naechsten Schritt. Reibung entsteht hier nicht als Erstes.';\n  } else if (hasForm || hasBookingLink || hasEmail || hasPhone) {\n    nextStepStatus = 'warning';\n    nextStepValue = 'vorhanden';\n    nextStepHelp = 'Kontakt ist moeglich, aber noch nicht stark genug gefuehrt.';\n    nextStepSummary = 'Interesse kann entstehen, aber der naechste Schritt ist noch nicht klar oder sichtbar genug priorisiert.';\n  } else {\n    nextStepStatus = 'bad';\n    nextStepValue = 'zu weich';\n    nextStepHelp = 'Kein sauber gefuehrter naechster Schritt gefunden.';\n    nextStepSummary = 'Die Seite laesst Interesse versickern, bevor daraus eine Anfrage wird.';\n  }\n}\n\nlet mobileStatus = 'warning';\nlet mobileValue = 'nicht messbar';\nlet mobileHelp = 'PageSpeed konnte heute nicht belastbar geladen werden.';\nlet mobileSummary = 'Die technische Basis ist heute nur eingeschraenkt messbar.';\nif (pagespeedOk) {\n  if (mobileScore >= 85 && lcpMs > 0 && lcpMs < 2500) {\n    mobileStatus = 'good';\n    mobileValue = 'stark';\n    mobileHelp = 'Mobile Score ' + mobileScore + '/100 \u00b7 LCP ' + lcp;\n    mobileSummary = 'Der mobile Eindruck wirkt nicht wie der groesste Blocker.';\n  } else if (mobileScore >= 60) {\n    mobileStatus = 'warning';\n    mobileValue = 'okay';\n    mobileHelp = 'Mobile Score ' + mobileScore + '/100 \u00b7 LCP ' + lcp;\n    mobileSummary = 'Die Seite ist mobil brauchbar, aber noch nicht stark genug fuer kalten Traffic.';\n  } else {\n    mobileStatus = 'bad';\n    mobileValue = 'zu langsam';\n    mobileHelp = 'Mobile Score ' + mobileScore + '/100 \u00b7 LCP ' + lcp;\n    mobileSummary = 'Der mobile Eindruck bremst frueher, als er sollte. Noch bevor Vertrauen entsteht, ist bereits Reibung da.';\n  }\n}\n\nconst diagnostics = {\n  promise: {\n    title: 'Seitenversprechen',\n    status: promiseStatus,\n    valueLabel: promiseValue,\n    helpLabel: promiseHelp,\n    summary: promiseSummary,\n    evidence: [\n      h1 ? 'H1: ' + truncate(h1, 90) : 'Keine H1 gefunden.',\n      title ? 'Title vorhanden.' : 'Kein sauberer Title gefunden.',\n      metaDesc ? 'Meta Description vorhanden.' : 'Keine saubere Meta Description gefunden.'\n    ],\n    action: 'Headline, Subline und CTA auf einen klaren Nutzen fuer einen klaren Besuchertyp zuspitzen.'\n  },\n  proof: {\n    title: 'Proof',\n    status: proofStatus,\n    valueLabel: proofValue,\n    helpLabel: proofHelp,\n    summary: proofSummary,\n    evidence: [\n      'Cases: ' + boolLabel(hasCases, 'gefunden', 'nicht gefunden'),\n      'Testimonials: ' + boolLabel(hasTestimonials, 'gefunden', 'nicht gefunden'),\n      'Zahlen oder Resultate: ' + boolLabel(hasStats, 'gefunden', 'nicht gefunden')\n    ],\n    action: 'Proof nicht verteilen, sondern direkt in den ersten sichtbaren Kaufkontext der Seite ziehen.'\n  },\n  nextStep: {\n    title: 'Naechster Schritt',\n    status: nextStepStatus,\n    valueLabel: nextStepValue,\n    helpLabel: nextStepHelp,\n    summary: nextStepSummary,\n    evidence: [\n      'Formular: ' + boolLabel(hasForm, 'vorhanden', 'nicht gefunden'),\n      'Terminbuchung: ' + boolLabel(hasBookingLink, 'vorhanden', 'nicht gefunden'),\n      'CTA im ersten Screen: ' + boolLabel(hasCtaAboveFold, 'erkennbar', 'nicht klar erkennbar')\n    ],\n    action: 'Nur einen naechsten Schritt priorisieren und ihn oberhalb des Folds klar anschlussfaehig machen.'\n  },\n  mobile: {\n    title: 'Mobiler Eindruck',\n    status: mobileStatus,\n    valueLabel: mobileValue,\n    helpLabel: mobileHelp,\n    summary: mobileSummary,\n    evidence: [\n      'Mobile Score: ' + (pagespeedOk ? mobileScore + '/100' : 'nicht messbar'),\n      'Largest Contentful Paint: ' + lcp,\n      'Total Blocking Time: ' + tbt\n    ],\n    action: 'Above-the-fold-Bereich, Bildgewicht und Skriptlast vor kosmetischen Aenderungen priorisieren.'\n  }\n};\n\nreturn [{ json: {\n  url: meta.url,\n  domain: meta.domain,\n  metaData: {\n    title,\n    metaDesc,\n    canonical,\n    h1,\n    h1Count: h1Matches.length,\n    topText\n  },\n  pageFacts: {\n    isHttps,\n    hasH1: !!h1,\n    hasCases,\n    hasTestimonials,\n    hasStats,\n    hasLogos,\n    hasForm,\n    hasBookingLink,\n    hasEmail,\n    hasPhone,\n    hasCtaAboveFold,\n    hasAudienceHint,\n    hasOutcomeHint,\n    hasSpecificityHint,\n    titleMatchesH1\n  },\n  performance: {\n    mobileScore,\n    lcp,\n    lcpMs,\n    cls,\n    clsVal,\n    tbt,\n    tbtMs\n  },\n  sourceHealth: {\n    rawHtmlOk,\n    rawHtmlStatus,\n    rawHtmlLength: html.length,\n    readerOk,\n    readerWordCount: wordCount(readerText),\n    pagespeedOk,\n    contentReliable\n  },\n  diagnostics,\n  rawSnapshot: {\n    firstScreen: topText,\n    title: truncate(title, 140),\n    h1: truncate(h1, 140)\n  }\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1500,
        -420
      ],
      "id": "df5e0c99-a558-46b0-a8b4-6a4489ba7d4d",
      "name": "Analyze Page"
    },
    {
      "parameters": {
        "jsCode": "const d = $('Analyze Page').first().json || {};\nconst diagnostics = d.diagnostics || {};\nconst metaData = d.metaData || {};\nconst facts = d.pageFacts || {};\nconst perf = d.performance || {};\nconst sourceHealth = d.sourceHealth || {};\n\nfunction severity(status) {\n  if (status === 'bad') return 3;\n  if (status === 'warning') return 2;\n  return 1;\n}\n\nfunction toneLabel(status) {\n  if (status === 'bad') return 'Kritisch';\n  if (status === 'warning') return 'Verbesserbar';\n  return 'Stark';\n}\n\nfunction phrase(title) {\n  switch (title) {\n    case 'Seitenversprechen': return 'das Seitenversprechen';\n    case 'Proof': return 'der sichtbare Beweis';\n    case 'Naechster Schritt': return 'der naechste Schritt';\n    case 'Mobiler Eindruck': return 'der mobile Eindruck';\n    default: return 'dieser Hebel';\n  }\n}\n\nfunction rowStatus(value, negativeStatus) {\n  return value ? 'good' : (negativeStatus || 'bad');\n}\n\nfunction rowValue(value, positive, negative) {\n  return value ? positive : negative;\n}\n\nfunction truncate(text, max) {\n  const value = String(text || '').trim();\n  if (!value) return '';\n  if (value.length <= max) return value;\n  return value.slice(0, Math.max(0, max - 1)).trim() + '\u2026';\n}\n\nconst ordered = ['Seitenversprechen', 'Proof', 'Naechster Schritt', 'Mobiler Eindruck'];\nconst allFindings = [diagnostics.promise, diagnostics.proof, diagnostics.nextStep, diagnostics.mobile]\n  .filter(Boolean)\n  .map((item) => ({\n    title: item.title,\n    status: item.status || 'warning',\n    summary: item.summary || '',\n    evidence: Array.isArray(item.evidence) ? item.evidence.slice(0, 3) : [],\n    action: item.action || '',\n    valueLabel: item.valueLabel || '',\n    helpLabel: item.helpLabel || '',\n    severity: severity(item.status || 'warning')\n  }))\n  .sort((a, b) => {\n    const diff = b.severity - a.severity;\n    if (diff !== 0) return diff;\n    return ordered.indexOf(a.title) - ordered.indexOf(b.title);\n  });\n\nconst topFindings = allFindings.slice(0, 3).map((item, index) => ({\n  title: item.title,\n  status: item.status,\n  impact: 'Hebel #' + (index + 1),\n  toneLabel: toneLabel(item.status),\n  summary: item.summary,\n  evidence: item.evidence,\n  action: item.action,\n  valueLabel: item.valueLabel,\n  helpLabel: item.helpLabel\n}));\n\nconst primary = topFindings[0] || { title: 'Seitenversprechen', status: 'warning', summary: 'Die Seite braucht eine klarere Priorisierung.' };\nconst secondary = topFindings[1] || null;\n\nconst badCount = allFindings.filter((item) => item.status === 'bad').length;\nconst warningCount = allFindings.filter((item) => item.status === 'warning').length;\n\nlet verdictStatus = 'warning';\nlet verdictBadge = 'Priorisierung noetig';\nif (badCount >= 2) {\n  verdictStatus = 'bad';\n  verdictBadge = 'Akute Bremsen';\n} else if (badCount === 0 && warningCount <= 1) {\n  verdictStatus = 'good';\n  verdictBadge = 'Starke Basis';\n}\n\nlet verdictHeadline = 'Bei ' + (d.domain || 'dieser Seite') + ' bremst vor allem ' + phrase(primary.title) + '.';\nif (secondary) {\n  verdictHeadline = 'Bei ' + (d.domain || 'dieser Seite') + ' bremsen vor allem ' + phrase(primary.title) + ' und ' + phrase(secondary.title) + '.';\n}\n\nlet verdictSubline = primary.summary || 'Die Analyse zeigt, welche Hebel zuerst priorisiert werden sollten.';\nif (!sourceHealth.contentReliable && !sourceHealth.pagespeedOk) {\n  verdictStatus = 'warning';\n  verdictBadge = 'Teilweise lesbar';\n  verdictHeadline = 'Die Seite war heute nur teilweise technisch lesbar, aber die Haupthebel lassen sich bereits priorisieren.';\n  verdictSubline = 'Fuer die genaue Umsetzung sollte man die Seite einmal live durchgehen. Die Richtung der Hebel ist trotzdem klar genug.';\n}\n\nconst highlights = topFindings.map((item) => ({\n  label: item.title,\n  value: item.valueLabel || toneLabel(item.status),\n  help: item.helpLabel || item.summary,\n  tone: item.status\n}));\n\nconst storyParts = [];\nstoryParts.push(\n  verdictStatus === 'bad'\n    ? 'Die Seite ist nicht kaputt. Sie arbeitet nur noch nicht sauber als Anfrage-Seite. ' + phrase(primary.title) + ' und ' + (secondary ? phrase(secondary.title) : 'ein weiterer Kernhebel') + ' greifen noch nicht sauber ineinander.'\n    : verdictStatus === 'good'\n      ? 'Die Seite hat bereits eine brauchbare Basis. Der groesste Hebel liegt jetzt weniger im Komplettumbau als in sauberer Zuspitzung.'\n      : 'Die Seite hat Substanz, aber noch nicht genug Klarheit im ersten Eindruck und im Uebergang zur Anfrage.'\n);\n\nstoryParts.push(\n  primary.title === 'Seitenversprechen'\n    ? 'Wenn die erste sichtbare Botschaft nicht schnell genug sagt, fuer wen die Seite gedacht ist und was sie konkret loest, wird selbst guter Traffic teurer als noetig.'\n    : primary.title === 'Proof'\n      ? 'Aufmerksamkeit alleine reicht nicht. Ohne sichtbaren Beweis fehlt der Grund, genau hier und nicht beim naechsten Anbieter anzufragen.'\n      : primary.title === 'Naechster Schritt'\n        ? 'Interesse darf nicht offen im Raum stehen bleiben. Ohne klaren naechsten Schritt verliert die Seite Momentum in dem Moment, in dem der Besucher eigentlich handeln koennte.'\n        : 'Der mobile Eindruck entscheidet frueher als viele denken. Wenn die Seite dort Reibung aufbaut, entstehen Zweifel schon vor dem eigentlichen Argument.'\n);\n\nstoryParts.push(\n  'Der sinnvolle naechste Schritt ist jetzt kein weiterer Tool-Report, sondern eine kurze Priorisierung direkt an der Seite. Danach ist klar, was sofort Wirkung bringt, was warten kann und welche Aenderung zuerst Anfragen wahrscheinlicher macht.'\n);\n\nconst frontendData = {\n  meta: {\n    mode: 'startseiten_anfragecheck',\n    domain: d.domain || '',\n    url: d.url || '',\n    branche: '',\n    standort: '',\n    generatedAt: new Date().toISOString()\n  },\n  verdict: {\n    status: verdictStatus,\n    badge: verdictBadge,\n    headline: verdictHeadline,\n    subline: verdictSubline\n  },\n  highlights,\n  findings: topFindings,\n  details: {\n    sections: [\n      {\n        title: 'Versprechen',\n        rows: [\n          { label: 'H1', value: metaData.h1 ? truncate(metaData.h1, 90) : 'nicht gefunden', status: metaData.h1 ? 'good' : 'bad' },\n          { label: 'Title', value: metaData.title ? truncate(metaData.title, 90) : 'nicht gefunden', status: metaData.title ? 'good' : 'warning' },\n          { label: 'Meta Description', value: metaData.metaDesc ? 'vorhanden' : 'nicht gefunden', status: metaData.metaDesc ? 'good' : 'warning' },\n          { label: 'Klarheit im ersten Screen', value: diagnostics.promise?.valueLabel || 'unbekannt', status: diagnostics.promise?.status || 'warning' }\n        ],\n        note: 'Hier entscheidet sich, ob ein Besucher in wenigen Sekunden versteht, worum es geht und warum er bleiben sollte.'\n      },\n      {\n        title: 'Proof',\n        rows: [\n          { label: 'Cases / Referenzen', value: rowValue(facts.hasCases, 'gefunden', 'nicht gefunden'), status: rowStatus(facts.hasCases, 'bad') },\n          { label: 'Testimonials / Reviews', value: rowValue(facts.hasTestimonials, 'gefunden', 'nicht gefunden'), status: rowStatus(facts.hasTestimonials, 'bad') },\n          { label: 'Zahlen oder Resultate', value: rowValue(facts.hasStats, 'gefunden', 'nicht gefunden'), status: rowStatus(facts.hasStats, 'warning') },\n          { label: 'HTTPS', value: rowValue(facts.isHttps, 'aktiv', 'fehlt'), status: rowStatus(facts.isHttps, 'bad') }\n        ],\n        note: 'Proof muss nicht laut sein, aber frueh genug sichtbar werden, damit aus Aufmerksamkeit Vertrauen wird.'\n      },\n      {\n        title: 'Naechster Schritt',\n        rows: [\n          { label: 'Formular', value: rowValue(facts.hasForm, 'vorhanden', 'nicht gefunden'), status: rowStatus(facts.hasForm, 'bad') },\n          { label: 'Terminbuchung', value: rowValue(facts.hasBookingLink, 'vorhanden', 'nicht gefunden'), status: rowStatus(facts.hasBookingLink, 'warning') },\n          { label: 'E-Mail oder direkter Kontakt', value: (facts.hasEmail || facts.hasPhone) ? 'vorhanden' : 'nicht gefunden', status: (facts.hasEmail || facts.hasPhone) ? 'good' : 'warning' },\n          { label: 'CTA im ersten Screen', value: rowValue(facts.hasCtaAboveFold, 'erkennbar', 'nicht klar erkennbar'), status: rowStatus(facts.hasCtaAboveFold, 'warning') }\n        ],\n        note: 'Interesse braucht einen klaren Anschluss. Sonst wird aus Besuch kein Gespraech.'\n      },\n      {\n        title: 'Mobiler Eindruck',\n        rows: [\n          { label: 'Mobile Score', value: sourceHealth.pagespeedOk ? String(perf.mobileScore || 0) + '/100' : 'nicht messbar', status: sourceHealth.pagespeedOk ? ((perf.mobileScore || 0) >= 85 ? 'good' : (perf.mobileScore || 0) >= 60 ? 'warning' : 'bad') : 'warning' },\n          { label: 'Largest Contentful Paint', value: perf.lcp || 'n/a', status: sourceHealth.pagespeedOk ? ((perf.lcpMs || 0) > 0 && (perf.lcpMs || 0) < 2500 ? 'good' : (perf.lcpMs || 0) < 4000 ? 'warning' : 'bad') : 'warning' },\n          { label: 'Cumulative Layout Shift', value: perf.cls || 'n/a', status: sourceHealth.pagespeedOk ? ((perf.clsVal || 0) > 0 && (perf.clsVal || 0) < 0.1 ? 'good' : 'warning') : 'warning' },\n          { label: 'Total Blocking Time', value: perf.tbt || 'n/a', status: sourceHealth.pagespeedOk ? ((perf.tbtMs || 0) < 250 ? 'good' : (perf.tbtMs || 0) < 600 ? 'warning' : 'bad') : 'warning' }\n        ],\n        note: 'Technik verkauft nicht allein. Sie entscheidet aber, wie sauber die Seite ihren ersten Eindruck ueberhaupt ausspielen kann.'\n      }\n    ]\n  },\n  story: storyParts.join('\\\\n\\\\n'),\n  cta: {\n    headline: 'Wenn Sie aus dieser Diagnose eine klare Reihenfolge machen wollen, ist jetzt der richtige Schritt das Gespraech.',\n    subline: 'Wir priorisieren gemeinsam, was auf dieser Seite zuerst Wirkung bringt und was bewusst warten kann.',\n    primary: {\n      label: 'Analyse gemeinsam priorisieren',\n      sublabel: '20 Minuten \u00b7 klare Reihenfolge \u00b7 keine Verkaufsshow'\n    },\n    secondary: {\n      url: 'https://cal.com/hasim/30min',\n      label: 'Direkt Strategiegespraech buchen'\n    }\n  }\n};\n\nreturn [{ json: { frontendData, domain: d.domain || '' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1740,
        -420
      ],
      "id": "652a521a-74c0-4f1a-97dc-283405ded0bb",
      "name": "Build Frontend Data"
    },
    {
      "parameters": {
        "jsCode": "const sd = $getWorkflowStaticData('global');\nconst meta = $('URL Validator').first().json || {};\nconst now = Date.now();\nconst ttlMs = 2 * 60 * 60 * 1000;\nconst frontendData = $json.frontendData || {};\n\nlet existing = {};\ntry {\n  existing = JSON.parse(sd[meta.jobId] || '{}');\n} catch (e) {}\n\nsd[meta.jobId] = JSON.stringify({\n  status: 'done',\n  data: frontendData,\n  request: {\n    url: meta.url,\n    domain: meta.domain || frontendData?.meta?.domain || null,\n    ip: meta.ip || '',\n    ua: meta.ua || '',\n  },\n  storedAt: existing.storedAt || now,\n  updatedAt: now,\n  finishedAt: now,\n  expiresAt: now + ttlMs,\n});\n\nconst hour = Math.floor(now / (60 * 60 * 1000));\nfor (const key of Object.keys(sd)) {\n  if (key.startsWith('job_')) {\n    try {\n      const entry = JSON.parse(sd[key]);\n      if (entry.expiresAt && entry.expiresAt < now) delete sd[key];\n    } catch (e) {\n      delete sd[key];\n    }\n    continue;\n  }\n\n  if (key.startsWith('rl:')) {\n    const keyHour = parseInt(key.split(':')[2] || '0', 10);\n    if (Number.isFinite(keyHour) && keyHour < hour - 1) delete sd[key];\n  }\n}\n\nreturn [{ json: { stored: true, jobId: meta.jobId, domain: frontendData?.meta?.domain || meta.domain || null, frontendData } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        -420
      ],
      "id": "a5b95a96-655c-4e1d-9f1a-8a4429594e24",
      "name": "Store Results"
    },
    {
      "parameters": {
        "path": "audit-status",
        "responseMode": "responseNode",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Methods",
                "value": "GET, OPTIONS"
              },
              {
                "name": "Cache-Control",
                "value": "no-cache"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -420,
        80
      ],
      "id": "5f09e9d5-f3d5-4169-bd2b-ef3fef918181",
      "name": "Webhook Status"
    },
    {
      "parameters": {
        "jsCode": "const sd = $getWorkflowStaticData('global');\nconst req = $('Webhook Status').first().json || {};\nconst jobId = req.query?.jobId || req.params?.jobId || '';\n\nif (!jobId || !jobId.startsWith('job_')) {\n  return [{ json: { ok: false, status: 'error', error: 'Ungueltige Job-ID.' } }];\n}\n\nconst raw = sd[jobId];\nif (!raw) {\n  return [{ json: { ok: true, status: 'processing', jobId, message: 'Check laeuft noch...' } }];\n}\n\nlet entry;\ntry {\n  entry = JSON.parse(raw);\n} catch (e) {\n  return [{ json: { ok: false, status: 'error', jobId, error: 'Fehler beim Laden.' } }];\n}\n\nif (entry.expiresAt && entry.expiresAt < Date.now()) {\n  delete sd[jobId];\n  return [{ json: { ok: false, status: 'expired', jobId, error: 'Ergebnis abgelaufen.' } }];\n}\n\nif (entry.status === 'done') {\n  return [{ json: { ok: true, status: 'done', jobId, data: entry.data } }];\n}\n\nif (entry.status === 'error') {\n  return [{ json: { ok: false, status: 'error', jobId, error: entry.error || 'Check fehlgeschlagen.' } }];\n}\n\nreturn [{ json: { ok: true, status: entry.status || 'processing', jobId, message: entry.message || 'Check laeuft noch...' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -180,
        80
      ],
      "id": "d07efb43-7200-4364-8220-a62774703461",
      "name": "Lookup Results"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        60,
        80
      ],
      "id": "4571cb37-d47c-4c9a-ad96-89c553b0cb61",
      "name": "Respond Status"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "audit-consultation",
        "responseMode": "responseNode",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              },
              {
                "name": "Access-Control-Allow-Methods",
                "value": "POST, OPTIONS"
              },
              {
                "name": "Cache-Control",
                "value": "no-cache"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -420,
        240
      ],
      "id": "2dae7916-8d63-4ce8-8246-4aa95258a999",
      "name": "Webhook Consultation"
    },
    {
      "parameters": {
        "jsCode": "const req = $input.first().json || {};\nconst body = req.body || {};\nconst ui = body.__submission?.user_inputs || body;\nconst errors = [];\n\nconst name = (ui.name || body.name || '').toString().trim();\nconst email = (ui.email || body.email || '').toString().trim();\nconst company = (ui.company || body.company || '').toString().trim();\nconst message = (ui.message || body.message || '').toString().trim();\nconst jobId = (ui.jobId || body.jobId || '').toString().trim();\nconst url = (ui.url || body.url || '').toString().trim();\nconst domain = (ui.domain || body.domain || '').toString().trim();\nconst primaryFinding = (ui.primaryFinding || body.primaryFinding || '').toString().trim();\nconst ip = (body.__submission?.ip || req.ip || req.headers?.['x-forwarded-for'] || '').toString();\nconst ua = (body.__submission?.browser || req.headers?.['user-agent'] || '').toString();\n\nif (!name) errors.push('Bitte Ihren Namen angeben.');\nif (!email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) errors.push('Bitte eine gueltige E-Mail-Adresse angeben.');\n\nconst subject = '[Check] Analyse priorisieren - ' + (company || domain || name);\nconst lines = [\n  'Neue Anfrage aus dem Startseiten-Check.',\n  '',\n  'Name: ' + name,\n  'E-Mail: ' + email,\n  company ? 'Unternehmen: ' + company : null,\n  domain ? 'Domain: ' + domain : null,\n  url ? 'URL: ' + url : null,\n  jobId ? 'Job ID: ' + jobId : null,\n  primaryFinding ? 'Primaerer Hebel: ' + primaryFinding : null,\n  '',\n  'Nachricht:',\n  message || '(keine Zusatznachricht)'\n].filter(Boolean);\n\nconst text = lines.join('\\n');\nconst html = [\n  '<div style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#0f172a;\">',\n  '<h2 style=\"margin:0 0 16px;\">Neue Anfrage aus dem Startseiten-Check</h2>',\n  '<p><strong>Name:</strong> ' + name + '</p>',\n  '<p><strong>E-Mail:</strong> ' + email + '</p>',\n  company ? '<p><strong>Unternehmen:</strong> ' + company + '</p>' : '',\n  domain ? '<p><strong>Domain:</strong> ' + domain + '</p>' : '',\n  url ? '<p><strong>URL:</strong> ' + url + '</p>' : '',\n  jobId ? '<p><strong>Job ID:</strong> ' + jobId + '</p>' : '',\n  primaryFinding ? '<p><strong>Primaerer Hebel:</strong> ' + primaryFinding + '</p>' : '',\n  '<hr style=\"border:none;border-top:1px solid #e2e8f0;margin:20px 0;\">',\n  '<p><strong>Nachricht:</strong></p>',\n  '<p>' + (message || '(keine Zusatznachricht)').replace(/\\n/g, '<br>') + '</p>',\n  '</div>'\n].join('');\n\nreturn [{ json: {\n  ok: errors.length === 0,\n  error: errors.length ? errors.join(' ') : null,\n  name,\n  email,\n  company,\n  message,\n  jobId,\n  url,\n  domain,\n  primaryFinding,\n  ip,\n  ua,\n  subject,\n  text,\n  html,\n  submittedAt: new Date().toISOString(),\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -180,
        240
      ],
      "id": "be2d1c4d-006e-4d39-abd9-07c344e9c76d",
      "name": "Consultation Validator"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -20,
        240
      ],
      "id": "1b6da0f8-13f6-4694-b501-5f0537eae43e",
      "name": "IF Consultation Valid"
    },
    {
      "parameters": {
        "fromEmail": "performance@hasimuener.de",
        "toEmail": "info@hasimuener.de",
        "subject": "={{ $json.subject }}",
        "html": "={{ $json.html }}"
      },
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        220,
        120
      ],
      "id": "1dcfed10-02ef-4ea5-9258-2d4bfef75f85",
      "name": "Send Consultation Notification",
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: true, status: \"accepted\", message: \"Anfrage ist raus. Wenn es schneller gehen soll, koennen Sie direkt einen Strategiecall buchen.\" } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        460,
        120
      ],
      "id": "f2c52ae4-53f8-4b19-956b-ade10c2b0ce3",
      "name": "Respond Consultation OK"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, status: \"error\", error: $json.error || \"Ungueltige Beratungsanfrage.\" } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        220,
        300
      ],
      "id": "0fff6d81-59d6-44ef-8011-8a729ec4fa60",
      "name": "Respond Consultation Error"
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "URL Validator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "URL Validator": {
      "main": [
        [
          {
            "node": "IF Valid Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Valid Request": {
      "main": [
        [
          {
            "node": "Respond OK",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond OK": {
      "main": [
        [
          {
            "node": "Store Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Store Processing": {
      "main": [
        [
          {
            "node": "Jina Reader",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Jina Reader": {
      "main": [
        [
          {
            "node": "Raw HTML Fetch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Raw HTML Fetch": {
      "main": [
        [
          {
            "node": "PageSpeed API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PageSpeed API": {
      "main": [
        [
          {
            "node": "Check PageSpeed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check PageSpeed": {
      "main": [
        [
          {
            "node": "IF PageSpeed Retry?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF PageSpeed Retry?": {
      "main": [
        [
          {
            "node": "Wait 12s",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "PageSpeed Final",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 12s": {
      "main": [
        [
          {
            "node": "PageSpeed API Retry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PageSpeed API Retry": {
      "main": [
        [
          {
            "node": "PageSpeed Final",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PageSpeed Final": {
      "main": [
        [
          {
            "node": "Analyze Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Page": {
      "main": [
        [
          {
            "node": "Build Frontend Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Frontend Data": {
      "main": [
        [
          {
            "node": "Store Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Status": {
      "main": [
        [
          {
            "node": "Lookup Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lookup Results": {
      "main": [
        [
          {
            "node": "Respond Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Consultation": {
      "main": [
        [
          {
            "node": "Consultation Validator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consultation Validator": {
      "main": [
        [
          {
            "node": "IF Consultation Valid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Consultation Valid": {
      "main": [
        [
          {
            "node": "Send Consultation Notification",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Consultation Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Consultation Notification": {
      "main": [
        [
          {
            "node": "Respond Consultation OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {},
  "meta": {
    "templateCredsSetupCompleted": true
  }
}