AutomationFlowsContent & Video › Customer Journey Audit Workflow with OpenAI

Customer Journey Audit Workflow with OpenAI

Original n8n title: Audit Funnel - Customer Journey Audit Runner V2

Audit Funnel - Customer Journey Audit Runner V2. Uses httpRequest, openAi, emailSend. Webhook trigger; 38 nodes.

Webhook trigger★★★★★ complexityAI-powered38 nodesHTTP RequestOpenAIEmail Send
Content & Video Trigger: Webhook Nodes: 38 Complexity: ★★★★★ AI nodes: yes Added:

This workflow follows the Emailsend → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

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

Download .json
{
  "name": "Audit Funnel - Customer Journey Audit Runner V2",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "audit",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -384,
        -400
      ],
      "id": "fcba7c74-bb4c-474b-8d6d-64dac8287b64",
      "name": "Webhook"
    },
    {
      "parameters": {
        "jsCode": "const req = $input.first().json || {};\nconst body = req.body || {};\nconst ui = body.__submission?.user_inputs || body;\nconst stepRaw = (ui.step || body.step || 'start').toString().trim().toLowerCase();\nconst mode = stepRaw === 'email_capture' ? 'email_capture' : 'start';\n\nlet url = (ui.url || body.url || '').toString().trim();\nlet email = (ui.email || body.email || '').toString().trim();\nconst requestedJobId = (ui.jobId || body.jobId || '').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 Website-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 ung\u00fcltig. 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\nif (mode === 'email_capture') {\n  if (!requestedJobId || !requestedJobId.startsWith('job_')) {\n    errors.push('Ung\u00fcltige Job-ID.');\n  }\n  if (!email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n    errors.push('Bitte eine g\u00fcltige E-Mail-Adresse angeben.');\n  }\n  if (url) {\n    url = normalizeUrl(url, false);\n  }\n} else {\n  url = normalizeUrl(url, true);\n  if (email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\n    errors.push('Bitte eine g\u00fcltige E-Mail-Adresse angeben.');\n  }\n\n  if (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/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}\n\nconst jobId = mode === 'email_capture'\n  ? requestedJobId\n  : 'job_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);\n\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    mode,\n    step: stepRaw,\n    error: errors.length ? errors.join(' ') : null,\n    url,\n    email,\n    domain,\n    jobId,\n    ip,\n    ua,\n    ts: new Date().toISOString(),\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -144,
        -400
      ],
      "id": "df87a1c4-f39e-47af-8f09-47e282ff2c5c",
      "name": "URL Validator"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        0,
        -400
      ],
      "id": "41387c66-4043-4615-ba48-1863b45e16eb",
      "name": "IF Valid Request"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: false, status: $json.status || \"error\", error: $json.error || \"Ung\u00fcltige Anfrage\" } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        240,
        -240
      ],
      "id": "d4b584b9-31a2-4b23-ab6b-3e5b1df634af",
      "name": "Respond Error"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.mode === \"email_capture\" }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        224,
        -400
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00001",
      "name": "IF Email Capture?"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: true, jobId: $json.jobId, status: \"processing\", message: \"Audit gestartet. Ergebnisse werden in ca. 30-60 Sekunden angezeigt.\", pollUrl: \"/webhook/audit-status?jobId=\" + $json.jobId } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        240,
        -480
      ],
      "id": "f7e33837-3218-4257-90e0-1d6215fdc3f0",
      "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: 'Audit l\u00e4uft...',\n  request: {\n    url: meta.url,\n    email: meta.email || '',\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": [
        432,
        -520
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00002",
      "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": [
        96,
        -400
      ],
      "id": "8f5f5c07-772d-4501-9665-767d68801f06",
      "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": [
        416,
        -336
      ],
      "id": "e28a40f8-5414-4ec7-84d5-29d85b249a22",
      "name": "Raw HTML Fetch",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=performance&category=seo&category=best-practices&category=accessibility",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $('URL Validator').first().json.url }}"
            },
            {
              "name": "strategy",
              "value": "mobile"
            },
            {
              "name": "category",
              "value": "performance"
            },
            {
              "name": "category",
              "value": "seo"
            },
            {
              "name": "category",
              "value": "best-practices"
            },
            {
              "name": "category",
              "value": "accessibility"
            },
            {
              "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": [
        576,
        -400
      ],
      "id": "17506824-228a-468d-b13f-b77bc476fd1d",
      "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 } }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        736,
        -400
      ],
      "id": "2e62e945-5f81-4cde-a27b-8d0131f8696e",
      "name": "Check PageSpeed"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.needRetry }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        896,
        -400
      ],
      "id": "6ccda909-ccbb-442d-8875-c14ace21402f",
      "name": "IF PageSpeed Retry?"
    },
    {
      "parameters": {
        "amount": 12,
        "unit": "seconds"
      },
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        1056,
        -240
      ],
      "id": "86a13e86-facd-4bef-b124-8f4f52711c31",
      "name": "Wait 12s"
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=performance&category=seo&category=best-practices&category=accessibility",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "url",
              "value": "={{ $('URL Validator').first().json.url }}"
            },
            {
              "name": "strategy",
              "value": "mobile"
            },
            {
              "name": "category",
              "value": "performance"
            },
            {
              "name": "category",
              "value": "seo"
            },
            {
              "name": "category",
              "value": "best-practices"
            },
            {
              "name": "category",
              "value": "accessibility"
            },
            {
              "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": [
        1216,
        -240
      ],
      "id": "f8913a0e-1b12-457a-8f44-0cee91e8aba4",
      "name": "PageSpeed API Retry",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// PageSpeed Final \u2014 pick best response (try1 vs retry) + unwrap common wrappers\n\nfunction 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 }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1216,
        -400
      ],
      "id": "8cf5f2c2-6214-4c0a-b914-1a1fc51df501",
      "name": "PageSpeed Final"
    },
    {
      "parameters": {
        "url": "={{ $('URL Validator').first().json.url }}/sitemap_index.xml",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 10000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1456,
        -400
      ],
      "id": "8605b5ad-5cac-4b29-af97-41a9fd71a9b0",
      "name": "Sitemap Fetch",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "url": "={{ $('URL Validator').first().json.url }}/sitemap.xml",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 10000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1616,
        -240
      ],
      "id": "8d197ff5-f570-4c59-8053-2dc39028481f",
      "name": "Sitemap Fetch Fallback",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "let a = '';\nlet b = '';\ntry { a = $('Sitemap Fetch').first().json?.data || $('Sitemap Fetch').first().json?.body || ''; } catch(e) {}\ntry { b = $('Sitemap Fetch Fallback').first().json?.data || $('Sitemap Fetch Fallback').first().json?.body || ''; } catch(e) {}\n\na = (typeof a === 'string') ? a : JSON.stringify(a||'');\nb = (typeof b === 'string') ? b : JSON.stringify(b||'');\n\nconst hasLocA = /<loc>[^<]+<\\/loc>/i.test(a);\nconst hasLocB = /<loc>[^<]+<\\/loc>/i.test(b);\n\nlet chosen = hasLocA ? a : (hasLocB ? b : a || b || '');\n\nreturn [{ json: { body: chosen, meta: { ok: !!chosen, used: hasLocA ? 'index' : (hasLocB ? 'fallback' : 'none') } } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1776,
        -400
      ],
      "id": "ff68f5b1-c164-4859-9131-6a02fa52c3a6",
      "name": "Sitemap Final"
    },
    {
      "parameters": {
        "jsCode": "const meta = $('URL Validator').first().json;\n\n// \u2550\u2550\u2550 PAGESPEED DIAGNOSE \u2550\u2550\u2550\nlet mobileScore = 0;\nlet scores = { performance: 0, seo: 0, bestPractices: 0, accessibility: 0, pwa: 0 };\nlet metrics = { LCP: 'n/a', LCP_ms: 0, CLS: 'n/a', CLS_val: 0, TBT: 'n/a', TBT_ms: 0, FCP: 'n/a', TTFB: 'n/a' };\nlet debugMsg = \"OK\";\n\ntry {\n  // Wichtig: Wir holen die Daten von FINAL (Retry-Logik)\n  const gd = $('PageSpeed Final').first().json;\n  \n  if (gd && gd.lighthouseResult) {\n    const lh = gd.lighthouseResult, au = lh.audits || {};\n    mobileScore = Math.round((lh.categories?.performance?.score || 0) * 100);\n    const cats = lh.categories || {};\n    const to100 = (v) => (typeof v === 'number' ? Math.round(v * 100) : 0);\n    scores = {\n      performance: to100(cats?.performance?.score),\n      seo: to100(cats?.seo?.score),\n      bestPractices: to100(cats?.['best-practices']?.score),\n      accessibility: to100(cats?.accessibility?.score),\n      pwa: to100(cats?.pwa?.score),\n    };\n    metrics = {\n      LCP: au['largest-contentful-paint']?.displayValue ?? 'n/a',\n      LCP_ms: au['largest-contentful-paint']?.numericValue || 0,\n      CLS: au['cumulative-layout-shift']?.displayValue ?? 'n/a',\n      CLS_val: au['cumulative-layout-shift']?.numericValue || 0,\n      TBT: au['total-blocking-time']?.displayValue ?? 'n/a',\n      TBT_ms: au['total-blocking-time']?.numericValue || 0,\n      FCP: au['first-contentful-paint']?.displayValue ?? 'n/a',\n      TTFB: au['server-response-time']?.displayValue ?? 'n/a'\n    };\n  } else {\n    if (gd.error) debugMsg = `Google Fehler: ${gd.error.message} (Code: ${gd.error.code})`;\n    else debugMsg = \"Google hat leeres Ergebnis geliefert.\";\n  }\n} catch(e) {\n  debugMsg = \"Script Fehler: \" + e.message;\n}\n\n// \u2550\u2550\u2550 HTML \u2550\u2550\u2550\nlet html = '', head = '';\ntry {\n  const rn = $('Raw HTML Fetch').first().json;\n  const raw = rn?.data || rn?.body || (typeof rn === 'string' ? rn : '') || '';\n  html = typeof raw === 'string' ? raw : JSON.stringify(raw);\n  const hm = html.match(/<head[^>]*>([\\s\\S]*?)<\\/head>/i);\n  head = hm ? hm[1] : html.substring(0, 8000);\n} catch(e) {}\n\nconst titleM = head.match(/<title[^>]*>([^<]{0,200})<\\/title>/i);\nconst title = titleM ? titleM[1].trim() : null;\nconst descM = head.match(/<meta[^>]*name=[\"']description[\"'][^>]*content=[\"']([^\"']{0,300})[\"']/i) || head.match(/<meta[^>]*content=[\"']([^\"']{0,300})[\"'][^>]*name=[\"']description[\"']/i);\nconst metaDesc = descM ? descM[1].trim() : null;\nconst canonical = (head.match(/<link[^>]*rel=[\"']canonical[\"'][^>]*href=[\"']([^\"']+)[\"']/i) || [])[1] || null;\nconst hasOg = (/<meta[^>]*property=[\"']og:(title|description|image)[\"']/gi.test(html));\nconst schemaBlocks = [...html.matchAll(/<script[^>]*type=[\"']application\\/ld\\+json[\"'][^>]*>([\\s\\S]*?)<\\/script>/gi)];\nconst schemaTypes = []; schemaBlocks.forEach(b => { try { const p = JSON.parse(b[1]); if (p['@type']) schemaTypes.push(p['@type']); if (Array.isArray(p['@graph'])) p['@graph'].forEach(i => { if (i['@type']) schemaTypes.push(i['@type']); }); } catch(e) {} });\nconst h1Count = (html.match(/<h1[^>]*>[\\s\\S]*?<\\/h1>/gi) || []).length;\n\n// Tracking\nconst hasGa4 = !!html.match(/G-[A-Z0-9]{6,12}/) || /gtag\\s*\\(/i.test(html);\nconst gtmM = html.match(/GTM-[A-Z0-9]{4,10}/);\nconst hasGtm = !!gtmM;\nconst gtmId = gtmM ? gtmM[0] : null;\nconst sgtmH = html.match(/https?:\\/\\/[a-z0-9.-]+\\.[a-z]+\\/gtm\\.js/i);\nconst hasSgtm = sgtmH ? !sgtmH[0].includes('googletagmanager.com') : false;\nconst consentTools = [];\nif (/borlabs/i.test(html)) consentTools.push('Borlabs');\nif (/cookiebot/i.test(html)) consentTools.push('Cookiebot');\nif (/complianz/i.test(html)) consentTools.push('Complianz');\nif (/usercentrics/i.test(html)) consentTools.push('Usercentrics');\nif (/real-cookie-banner/i.test(html)) consentTools.push('Real Cookie Banner');\nconst hasConsent = consentTools.length > 0;\nconst paidPixels = [];\nif (/fbq\\(|connect\\.facebook\\.net/i.test(html)) paidPixels.push('Meta');\nif (/AW-[0-9]+|googleads/i.test(html)) paidPixels.push('Google Ads');\nif (/linkedin\\.com\\/insight/i.test(html)) paidPixels.push('LinkedIn');\n\n// Security\nconst isWP = /wp-content|wp-includes|wp-json/i.test(html);\nconst wpVer = (html.match(/<meta[^>]*name=[\"']generator[\"'][^>]*content=[\"']WordPress\\s*([\\d.]+)[\"']/i) || [])[1] || null;\nconst isHttps = meta.url.startsWith('https');\n\n// Sitemap\nlet sitemapUrls = 0, blogUrls = 0, hasSitemap = false;\ntry {\n  const smR = $('Sitemap Final').first().json?.data || $('Sitemap Final').first().json?.body || '';\n  const sm = typeof smR === 'string' ? smR : '';\n  if (/<sitemapindex/i.test(sm)) {\n    const subs = [...sm.matchAll(/<loc>([^<]+)<\\/loc>/g)].map(m => m[1]);\n    hasSitemap = subs.length > 0; sitemapUrls = subs.length * 10;\n    blogUrls = subs.filter(u => /post/i.test(u)).length > 0 ? 5 : 0;\n  } else {\n    const urls = [...sm.matchAll(/<loc>([^<]+)<\\/loc>/g)].map(m => m[1]);\n    hasSitemap = urls.length > 0; sitemapUrls = urls.length;\n    blogUrls = urls.filter(u => /blog|artikel|beitrag|post|ratgeber/i.test(u)).length;\n  }\n} catch(e) {}\nif (!hasSitemap) {\n  if (/<link[^>]*rel=[\"']sitemap[\"']/i.test(html) || /sitemap_index\\.xml|sitemap\\.xml|wp-sitemap/i.test(html)) { hasSitemap = true; sitemapUrls = 10; }\n  else if (isWP) { hasSitemap = true; sitemapUrls = 5; }\n}\n\n// Jina Text\nlet jinaText = '';\ntry { const jn = $('Jina Reader').first().json; jinaText = (jn?.data || jn?.body || ''); } catch(e) {}\nconst jLow = jinaText.toLowerCase();\nconst wordCount = jinaText.split(/\\s+/).length;\nconst hasImpressum = /impressum/i.test(jinaText);\nconst hasDatenschutz = /datenschutz|privacy/i.test(jinaText);\n\n// Content signals\nconst hasBlog = blogUrls > 0 || /blog/i.test(jinaText) || /blog|artikel|magazin|ratgeber/i.test(html);\nconst hasCases = /case.?stud|referenz|fallstud|projekt|erfolgsgeschichte|portfolio|kundenprojekt/i.test(jLow) || /case|referenz|portfolio/i.test(html);\nconst hasTestimonials = /testimonial|kundenstimm|bewertung|rezension|\\d+\\s*sterne|google.*review|trustpilot|ausgezeichnet|empfehl|zufried|feedback/i.test(jLow);\nconst hasLeadMagnet = /lead.?magnet|download|whitepaper|e-?book|checkliste|gratis|kostenlos.*(guide|download|pdf|audit|analyse|check)|freebie|newsletter|kostenloses?.*(gespr|berat|analys|check|call)|jetzt.*anfrag|jetzt.*starten/i.test(jLow);\nconst hasContactForm = /kontakt|contact|formular|anfrage|nachricht.*senden/i.test(jLow);\nconst hasPhone = /\\+?\\d{2,4}[\\s.-]?\\d{3,}[\\s.-]?\\d{3,}/g.test(jinaText);\n\nreturn [{ json: {\n  url: meta.url, email: meta.email, domain: meta.domain,\n  speed: { mobileScore, metrics, scores },\n  debug_error: debugMsg,\n  seo: { title, metaDesc, canonical, hasOg, schemaTypes, h1Count, hasSitemap, sitemapUrls },\n  tracking: { hasGa4, hasGtm, gtmId, hasSgtm, hasConsent, consentTools, paidPixels },\n  security: { isWP, wpVer, isHttps, hasImpressum, hasDatenschutz },\n  content: { hasBlog, blogUrls, hasCases, hasTestimonials, hasLeadMagnet, hasContactForm, hasPhone, wordCount },\n  jinaText: jinaText.substring(0, 3000)\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2016,
        -400
      ],
      "id": "3314c901-cf84-4357-9bd4-994c92d685ab",
      "name": "Analyze Site"
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-5.2",
          "mode": "id"
        },
        "responses": {
          "values": [
            {
              "role": "system",
              "content": "Du bist ein SEO-Experte der Keywords mit kommerziellem Intent identifiziert.\n\nAnalysiere die Website und generiere 6 Suchbegriffe die ein ZAHLENDER Kunde bei Google eingeben wuerde \u2014 nicht jemand der sich informieren will, sondern jemand der KAUFEN oder BEAUFTRAGEN will.\n\nRegeln:\n1. Erkenne: Branche, Kerndienstleistungen, Stadt/Region\n2. NUR deutsche Keywords\n3. Mix: 2x [Dienstleistung + Stadt] (z.B. \"webdesign agentur hannover\"), 2x [Dienstleistung + Kaufintent] (z.B. \"website erstellen lassen kosten\"), 2x [Problem mit Loesungsintent] (z.B. \"wordpress seite zu langsam was tun\")\n4. Suchvolumen: Schaetze KONSERVATIV und realistisch. Lokale Keywords (mit Stadt): 100-600. Allgemeine: 300-2000. Nischen-Longtail: 50-300. NICHT uebertreiben.\n5. Kundenwert: Schaetze den durchschnittlichen Auftragswert fuer EINEN Neukunden in dieser Branche. Webdesign: 3000-8000. Beratung: 1500-5000. E-Commerce: 5000-15000.\n\nAntworte NUR mit JSON \u2014 kein anderer Text, keine Backticks:\n{\"branche\": \"...\", \"standort\": \"...\", \"kundenwert_eur\": 3000, \"keywords\": [{\"q\": \"webdesign agentur hannover\", \"vol\": 400}, ...]}"
            },
            {
              "content": "=Website: {{ $json.domain }}\nDienstleistungen und Inhalte:\n{{ $json.jinaText }}"
            }
          ]
        },
        "builtInTools": {},
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 2.1,
      "position": [
        2256,
        -400
      ],
      "id": "5b05e05a-42ed-4099-ad38-2d11dcf9855c",
      "name": "Keyword Generator",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Read GPT output\nconst j = $('Keyword Generator').first().json;\nlet raw = '';\nif (j?.output?.[0]?.content?.[0]?.text) raw = j.output[0].content[0].text;\nelse if (j?.content?.[0]?.text) raw = j.content[0].text;\nelse if (typeof j?.message?.content === 'string') raw = j.message.content;\nelse if (typeof j?.text === 'string') raw = j.text;\nelse raw = JSON.stringify(j);\n\n// Clean and parse JSON\nraw = raw.replace(/```json|```/g, '').trim();\nlet parsed;\ntry { parsed = JSON.parse(raw); } catch(e) {\n  // Fallback: extract JSON from text\n  const m = raw.match(/\\{[\\s\\S]*\\}/);\n  if (m) parsed = JSON.parse(m[0]);\n  else parsed = { branche: 'Unbekannt', standort: '', kundenwert_eur: 3000, keywords: [{ q: 'dienstleistung', vol: 300 }] };\n}\n\nconst site = $('Analyze Site').first().json;\nconst kws = parsed.keywords || [];\n\n// Output one item per keyword for SERP check\nconst items = kws.slice(0, 6).map(kw => ({\n  json: {\n    keyword: kw.q,\n    volume: kw.vol || 300,\n    domain: site.domain,\n    branche: parsed.branche || '',\n    standort: parsed.standort || '',\n    kundenwert: parsed.kundenwert_eur || 3000\n  }\n}));\n\nreturn items;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2496,
        -400
      ],
      "id": "45c2b18b-5e0d-44dd-b4f3-6c7a5f6242be",
      "name": "Extract Keywords"
    },
    {
      "parameters": {
        "url": "https://www.googleapis.com/customsearch/v1",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "key",
              "value": "={{ $vars.GOOGLE_CSE_KEY || $env.GOOGLE_CSE_KEY }}"
            },
            {
              "name": "cx",
              "value": "={{ $vars.GOOGLE_CSE_CX || $env.GOOGLE_CSE_CX || '0063ebe7a8a39425d' }}"
            },
            {
              "name": "q",
              "value": "={{ $json.keyword }}"
            },
            {
              "name": "gl",
              "value": "de"
            },
            {
              "name": "hl",
              "value": "de"
            },
            {
              "name": "num",
              "value": "10"
            }
          ]
        },
        "options": {
          "timeout": 15000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        2736,
        -400
      ],
      "id": "34288a61-aa48-4497-8383-a7cfdf560afd",
      "name": "SERP Check",
      "alwaysOutputData": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Collect all SERP results and site data\nconst serpItems = $input.all();\nconst site = $('Analyze Site').first().json;\nconst kwItems = $('Extract Keywords').all();\n\nconst kwData = kwItems[0]?.json || {};\nconst branche = kwData.branche || 'Dienstleister';\nconst standort = kwData.standort || '';\nconst kundenwert = kwData.kundenwert || 3000;\n\n// Process SERP results\nconst serpResults = [];\nlet totalLostTraffic = 0;\n\nfor (let i = 0; i < serpItems.length; i++) {\n  const serp = serpItems[i].json;\n  const kw = kwItems[i]?.json || {};\n  const keyword = kw.keyword || 'unbekannt';\n  const volume = kw.volume || 300;\n  const domain = kw.domain || site.domain;\n  \n  // Check if domain appears in results\n  const items = serp?.items || [];\n  let position = -1;\n  let competitors = [];\n  \n  for (let r = 0; r < items.length; r++) {\n    const link = (items[r]?.link || '').toLowerCase();\n    const displayLink = (items[r]?.displayLink || '').toLowerCase();\n    if (link.includes(domain) || displayLink.includes(domain)) {\n      position = r + 1;\n    } else if (competitors.length < 3) {\n      competitors.push({ name: items[r]?.displayLink || '', title: items[r]?.title || '' });\n    }\n  }\n  \n  // Estimate lost traffic if not ranking\n  let lostTraffic = 0;\n  if (position === -1) {\n    lostTraffic = Math.round(volume * 0.03); // ~3% CTR for page 1 avg\n  } else if (position > 3) {\n    lostTraffic = Math.round(volume * 0.015); // could get more traffic if higher\n  }\n  totalLostTraffic += lostTraffic;\n  \n  serpResults.push({\n    keyword,\n    volume,\n    position, // -1 = not found\n    lostTraffic,\n    competitors: competitors.slice(0, 2),\n    status: position === -1 ? 'not_found' : position <= 3 ? 'top3' : position <= 10 ? 'page1' : 'low'\n  });\n}\n\n// Revenue Gap\nconst conversionRate = 0.02;\nconst lostLeadsMonth = Math.round(totalLostTraffic * conversionRate * 10) / 10;\nconst lostRevenueMonth = Math.round(lostLeadsMonth * kundenwert);\nconst lostRevenueYear = lostRevenueMonth * 12;\n\n// Journey Steps\nconst s = site;\nconst steps = [];\n\n// Step 1: Google Search\nconst notRanking = serpResults.filter(r => r.status === 'not_found');\nconst ranking = serpResults.filter(r => r.status !== 'not_found');\nsteps.push({\n  nr: 1,\n  title: 'Ihr Kunde sucht bei Google',\n  icon: '\\uD83D\\uDD0D',\n  results: serpResults,\n  summary: notRanking.length + ' von ' + serpResults.length + ' Keywords: nicht auf Seite 1',\n  status: notRanking.length === 0 ? 'good' : notRanking.length <= 2 ? 'warning' : 'bad'\n});\n\n// Step 2: Website Visit\nconst loadTime = s.speed.metrics.LCP || 'n/a';\nconst loadMs = s.speed.metrics.LCP_ms || 0;\nsteps.push({\n  nr: 2,\n  title: 'Ein Besucher landet auf Ihrer Seite',\n  icon: '\\u26A1',\n  details: [\n    { label: 'Ladezeit (LCP)', value: loadTime, status: loadMs < 2500 ? 'good' : loadMs < 4000 ? 'warning' : 'bad' },\n    { label: 'Mobile Score', value: s.speed.mobileScore + '/100', status: s.speed.mobileScore >= 90 ? 'good' : s.speed.mobileScore >= 50 ? 'warning' : 'bad' },\n    { label: 'SEO Score', value: (s.speed.scores?.seo ?? 0) + '/100', status: (s.speed.scores?.seo ?? 0) >= 90 ? 'good' : (s.speed.scores?.seo ?? 0) >= 70 ? 'warning' : 'bad' },\n    { label: 'Best Practices', value: (s.speed.scores?.bestPractices ?? 0) + '/100', status: (s.speed.scores?.bestPractices ?? 0) >= 90 ? 'good' : (s.speed.scores?.bestPractices ?? 0) >= 70 ? 'warning' : 'bad' },\n    { label: 'Accessibility', value: (s.speed.scores?.accessibility ?? 0) + '/100', status: (s.speed.scores?.accessibility ?? 0) >= 90 ? 'good' : (s.speed.scores?.accessibility ?? 0) >= 70 ? 'warning' : 'bad' },\n    { label: 'Layout-Stabilit\u00e4t (CLS)', value: s.speed.metrics.CLS, status: s.speed.metrics.CLS_val < 0.1 ? 'good' : 'warning' }\n  ],\n  summary: s.speed.mobileScore >= 90 ? 'Schneller als 90% aller Websites. Der Besucher bleibt.' : s.speed.mobileScore >= 50 ? 'Akzeptabel, aber Verbesserungspotenzial.' : 'Zu langsam. Besucher springen ab.',\n  status: s.speed.mobileScore >= 90 ? 'good' : s.speed.mobileScore >= 50 ? 'warning' : 'bad'\n});\n\n// Step 3: Trust\nsteps.push({\n  nr: 3,\n  title: 'Der Besucher sucht einen Grund zu vertrauen',\n  icon: '\\uD83E\\uDD1D',\n  details: [\n    { label: 'Referenzen / Cases', value: s.content.hasCases ? 'Vorhanden' : 'Nicht gefunden', status: s.content.hasCases ? 'good' : 'bad' },\n    { label: 'Kundenstimmen / Testimonials', value: s.content.hasTestimonials ? 'Vorhanden' : 'Nicht gefunden', status: s.content.hasTestimonials ? 'good' : 'bad' },\n    { label: 'SSL / HTTPS', value: s.security.isHttps ? 'Aktiv' : 'Fehlt', status: s.security.isHttps ? 'good' : 'bad' },\n    { label: 'Impressum', value: s.security.hasImpressum ? 'Vorhanden' : 'Nicht gefunden', status: s.security.hasImpressum ? 'good' : 'bad' }\n  ],\n  summary: (s.content.hasCases && s.content.hasTestimonials) ? 'Starker Social Proof. Der Besucher fasst Vertrauen.' : s.content.hasCases ? 'Cases vorhanden, aber keine echten Kundenstimmen. Vertrauen bleibt unvollst\u00e4ndig.' : 'Kein Social Proof. Der Besucher vergleicht und w\u00e4hlt den Anbieter mit Beweisen.',\n  status: (s.content.hasCases && s.content.hasTestimonials) ? 'good' : s.content.hasCases || s.content.hasTestimonials ? 'warning' : 'bad'\n});\n\n// Step 4: Contact/Lead\nsteps.push({\n  nr: 4,\n  title: 'Der Besucher will den n\u00e4chsten Schritt machen',\n  icon: '\\uD83C\\uDFAF',\n  details: [\n    { label: 'Kontaktformular', value: s.content.hasContactForm ? 'Vorhanden' : 'Nicht gefunden', status: s.content.hasContactForm ? 'good' : 'bad' },\n    { label: 'Telefonnummer sichtbar', value: s.content.hasPhone ? 'Ja' : 'Nicht gefunden', status: s.content.hasPhone ? 'good' : 'warning' },\n    { label: 'Lead-Magnet (Guide, Checkliste)', value: s.content.hasLeadMagnet ? 'Vorhanden' : 'Nicht gefunden', status: s.content.hasLeadMagnet ? 'good' : 'bad' },\n    { label: 'Blog / Content Hub', value: s.content.hasBlog ? 'Vorhanden' : 'Nicht gefunden', status: s.content.hasBlog ? 'good' : 'bad' }\n  ],\n  summary: s.content.hasLeadMagnet ? 'Gute Lead-Capture Optionen vorhanden.' : 'Kein Lead-Magnet. 97% der Erstbesucher kommen nie wieder \u2014 ohne Opt-in gehen sie f\u00fcr immer verloren.',\n  status: s.content.hasLeadMagnet ? 'good' : s.content.hasContactForm ? 'warning' : 'bad'\n});\n\n// Build prompt for Story Writer\nlet serpSummary = '';\nserpResults.forEach(r => {\n  const icon = r.status === 'not_found' ? 'NICHT GEFUNDEN' : r.status === 'top3' ? 'TOP 3' : 'Position ' + r.position;\n  serpSummary += '- \"' + r.keyword + '\" (~' + r.volume + '/Monat): ' + icon;\n  if (r.competitors.length > 0 && r.status === 'not_found') serpSummary += ' | Stattdessen: ' + r.competitors.map(c => c.name).join(', ');\n  serpSummary += '\\n';\n});\n\nconst storyPrompt = 'Website: ' + s.domain + ' | Branche: ' + branche + ' | Standort: ' + standort + '\\n\\nGOOGLE-SICHTBARKEIT:\\n' + serpSummary + '\\nPERFORMANCE: Mobile ' + s.speed.mobileScore + '/100, LCP ' + s.speed.metrics.LCP + '\\nTRUST: Cases=' + (s.content.hasCases ? 'Ja' : 'Nein') + ', Testimonials=' + (s.content.hasTestimonials ? 'Ja' : 'Nein') + '\\nLEAD-CAPTURE: LeadMagnet=' + (s.content.hasLeadMagnet ? 'Ja' : 'Nein') + ', Blog=' + (s.content.hasBlog ? 'Ja' : 'Nein') + ', Kontaktformular=' + (s.content.hasContactForm ? 'Ja' : 'Nein') + '\\nTRACKING: GTM=' + (s.tracking.hasGtm ? s.tracking.gtmId : 'Nein') + ', Consent=' + s.tracking.consentTools.join('+') + ', Paid=' + (s.tracking.paidPixels.length > 0 ? s.tracking.paidPixels.join('+') : 'keine') + '\\n\\nREVENUE GAP: ~' + totalLostTraffic + ' verlorene Besucher/Monat, ~' + lostLeadsMonth + ' verlorene Leads/Monat, ~' + lostRevenueMonth + ' EUR/Monat (~' + lostRevenueYear + ' EUR/Jahr)';\n\nreturn [{ json: {\n  story_prompt: storyPrompt,\n  steps, serpResults,\n  revenue: { totalLostTraffic, lostLeadsMonth, lostRevenueMonth, lostRevenueYear, kundenwert, conversionRate: '2%' },\n  branche, standort, kundenwert,\n  url: s.url, email: s.email, domain: s.domain,\n  site: s\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2976,
        -400
      ],
      "id": "97e0867b-94e5-41ab-a525-526f6c89c159",
      "name": "Build Journey"
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-5.2",
          "mode": "id"
        },
        "responses": {
          "values": [
            {
              "role": "system",
              "content": "Du schreibst wie ein Berater der 500 EUR pro Stunde nimmt. Kein Wort ist umsonst. Jeder Satz erzeugt entweder Erkenntnis oder Handlungsdruck.\n\nDu bekommst Daten aus einer Customer-Journey-Simulation. Der Empfaenger hat die Journey-Schritte bereits gesehen. Dein Text ergaenzt \u2014 NICHT wiederholen.\n\nSchreibe GENAU 3 Absaetze (MAXIMAL 180 Woerter total):\n\nABSATZ 1 \u2014 DAS MUSTER (3 Saetze max):\nVerbinde die Schwaechen zu EINEM klaren Bild. Benutze eine praegnante Metapher die haengenbleibt. Nicht beschreiben was fehlt \u2014 sondern was PASSIERT weil es fehlt. Schlecht: \"Ihnen fehlt Content und Testimonials.\" Gut: \"Ihre Website ist ein Schaufenster in einer Seitenstrasse \u2014 schoen dekoriert, aber niemand laeuft vorbei.\"\n\nABSATZ 2 \u2014 WAS DAS KOSTET (2-3 Saetze):\nNimm die Revenue-Gap-Zahl und mach sie greifbar. Nicht \"9.000 EUR pro Monat entgehen Ihnen\" sondern: \"9.000 EUR pro Monat \u2014 das ist ein Vollzeit-Mitarbeiter, der jeden Monat Kunden bringt, den Sie aber nie eingestellt haben.\" Oder: \"In 12 Monaten sind das 108.000 EUR. Genug fuer 3 Entwickler oder 36 Monate Werbebudget.\"\n\nABSATZ 3 \u2014 DER NAECHSTE SCHRITT (2 Saetze):\nEine spezifische Frage die nur mit echten Daten beantwortet werden kann. Nicht generisch. Spezifisch fuer DIESE Website, DIESE Branche, DIESEN Standort.\n\nVERBOTEN: Woerter wie \"grundsaetzlich\", \"durchaus\", \"zweifellos\", \"erheblich\", \"signifikant\", \"massgeblich\". Keine Relativierungen. Keine Konjunktive. Keine Aufzaehlungen. Kein Markdown. Max 180 Woerter."
            },
            {
              "content": "={{ $json.story_prompt }}"
            }
          ]
        },
        "builtInTools": {},
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 2.1,
      "position": [
        3216,
        -400
      ],
      "id": "a4a23e8f-e591-475c-8c35-3c00bdc4b287",
      "name": "Write Story",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Build clean JSON payload for WordPress frontend display\nconst d = $('Build Journey').first().json;\nconst site = d.site;\nconst steps = d.steps;\nconst serp = d.serpResults;\nconst rev = d.revenue;\n\n// Read story\nconst sj = $('Write Story').first().json;\nlet story = '';\nif (sj?.output?.[0]?.content?.[0]?.text) story = sj.output[0].content[0].text;\nelse if (sj?.content?.[0]?.text) story = sj.content[0].text;\nelse if (typeof sj?.message?.content === 'string') story = sj.message.content;\nelse if (typeof sj?.text === 'string') story = sj.text;\nelse story = '';\nstory = story.replace(/\\*\\*/g, '').replace(/#+\\s/g, '');\n\n// Build frontend-optimized JSON\nconst frontendData = {\n  meta: {\n    domain: d.domain,\n    url: d.url,\n    branche: d.branche,\n    standort: d.standort,\n    generatedAt: new Date().toISOString()\n  },\n  scores: {\n    mobile: site.speed.mobileScore,\n    seo: {\n      hasTitle: !!site.seo.title,\n      hasMetaDesc: !!site.seo.metaDesc,\n      hasCanonical: !!site.seo.canonical,\n      hasOg: site.seo.hasOg,\n      hasSitemap: site.seo.hasSitemap,\n      h1Count: site.seo.h1Count,\n      schemaTypes: site.seo.schemaTypes\n    },\n    tracking: {\n      hasGa4: site.tracking.hasGa4,\n      hasGtm: site.tracking.hasGtm,\n      gtmId: site.tracking.gtmId,\n      hasSgtm: site.tracking.hasSgtm,\n      hasConsent: site.tracking.hasConsent,\n      consentTools: site.tracking.consentTools,\n      paidPixels: site.tracking.paidPixels\n    }\n  },\n  performance: {\n    mobileScore: site.speed.mobileScore,\n    lcp: site.speed.metrics.LCP,\n    lcpMs: site.speed.metrics.LCP_ms,\n    cls: site.speed.metrics.CLS,\n    clsVal: site.speed.metrics.CLS_val,\n    tbt: site.speed.metrics.TBT,\n    tbtMs: site.speed.metrics.TBT_ms,\n    fcp: site.speed.metrics.FCP,\n    ttfb: site.speed.metrics.TTFB\n  },\n  journeySteps: steps,\n  serpResults: serp,\n  revenue: rev,\n  story: story,\n  cta: {\n    url: '#deep-dive-section',\n    label: '360\u00b0 Deep-Dive starten',\n    sublabel: '5 gezielte Fragen \u00b7 Kein Sales-Call \u00b7 Pers\u00f6nliche Analyse in 48h',\n    altUrl: 'https://cal.com/hasim/30min',\n    altLabel: 'Oder direkt: Strategiecall buchen \u2192'\n  }\n};\n\nreturn [{ json: { frontendData, email: d.email, domain: d.domain } }];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3696,
        -400
      ],
      "id": "d218c76f-60ca-4b4d-a9df-4de2a0e0743c",
      "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    email: meta.email || '',\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 [{\n  json: {\n    stored: true,\n    jobId: meta.jobId,\n    email: ($json.email || '').toString(),\n    domain: $json.domain || frontendData?.meta?.domain || meta.domain || null,\n    frontendData,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3936,
        -400
      ],
      "id": "ec3dfdd9-bdba-4654-a62f-81a03f13e177",
      "name": "Store Results"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ !!($json.email && $json.email.toString().trim()) }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        4176,
        -520
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00003",
      "name": "IF Auto Email Requested"
    },
    {
      "parameters": {
        "jsCode": "return [{\n  json: {\n    mode: 'auto_email',\n    jobId: $json.jobId,\n    email: ($json.email || '').toString().trim(),\n    frontendData: $json.frontendData || {},\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4416,
        -600
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00004",
      "name": "Prepare Auto Email Request"
    },
    {
      "parameters": {
        "jsCode": "const payload = $json.frontendData || {};\nconst meta = payload.meta || {};\nconst steps = Array.isArray(payload.journeySteps) ? payload.journeySteps : [];\nconst serp = Array.isArray(payload.serpResults) ? payload.serpResults : [];\nconst rev = Object.assign({\n  totalLostTraffic: 0,\n  conversionRate: '2%',\n  kundenwert: 0,\n  lostRevenueMonth: 0,\n  lostRevenueYear: 0,\n}, payload.revenue || {});\nconst story = (payload.story || '').toString().trim();\nconst email = ($json.email || '').toString().trim();\nconst mode = ($json.mode || 'auto_email').toString();\nconst siteLabel = meta.domain || meta.url || 'Ihre Website';\nconst fileBase = (meta.domain || 'report').replace(/[^a-zA-Z0-9-]/g, '-');\nconst firstGap = serp.find((item) => item.status === 'not_found') || serp[0] || null;\nconst subjectHook = firstGap ? '\u201e' + firstGap.keyword + '\u201c \u2014 Ihre Kunden suchen, aber finden Sie nicht' : 'Customer Journey Audit';\n\nfunction esc(value) {\n  return String(value ?? '')\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;');\n}\n\nfunction money(value) {\n  const num = Number(value || 0);\n  return num.toLocaleString('de-DE');\n}\n\nlet html = '';\nhtml += '<div style=\"font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#f1f5f9;padding:20px;margin:0;\">';\nhtml += '<div style=\"max-width:680px;margin:0 auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.08);\">';\nhtml += '<div style=\"background:#0f172a;padding:40px 32px;text-align:center;\">';\nhtml += '<div style=\"font-size:11px;letter-spacing:3px;text-transform:uppercase;color:#ffb020;font-weight:800;margin-bottom:8px;\">Customer Journey Audit</div>';\nhtml += '<h1 style=\"font-size:24px;line-height:1.25;color:#fff;margin:0 0 8px;\">Wir haben die Reise Ihres n\u00e4chsten Kunden simuliert.</h1>';\nhtml += '<p style=\"font-size:14px;color:#cbd5e1;margin:0;\">Website: <strong style=\"color:#fff;\">' + esc(siteLabel) + '</strong></p>';\nhtml += '</div>';\n\nhtml += '<div style=\"padding:28px 32px 8px;\">';\nhtml += '<h2 style=\"font-size:18px;color:#0f172a;margin:0 0 12px;\">Kernaussagen</h2>';\nhtml += '<div style=\"display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;\">';\nhtml += '<div style=\"background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px;\">';\nhtml += '<div style=\"font-size:12px;color:#64748b;\">Potenzial pro Monat</div>';\nhtml += '<div style=\"font-size:22px;font-weight:800;color:#ef4444;\">~' + money(rev.lostRevenueMonth) + ' \u20ac</div>';\nhtml += '</div>';\nhtml += '<div style=\"background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px;\">';\nhtml += '<div style=\"font-size:12px;color:#64748b;\">Potenzial pro Jahr</div>';\nhtml += '<div style=\"font-size:22px;font-weight:800;color:#ef4444;\">~' + money(rev.lostRevenueYear) + ' \u20ac</div>';\nhtml += '</div>';\nhtml += '</div>';\nhtml += '</div>';\n\nhtml += '<div style=\"padding:8px 32px 24px;\">';\nfor (const step of steps) {\n  const status = step.status || 'warning';\n  const color = status === 'good' ? '#16a34a' : status === 'bad' ? '#dc2626' : '#d97706';\n  const bg = status === 'good' ? '#f0fdf4' : status === 'bad' ? '#fef2f2' : '#fffbeb';\n  html += '<div style=\"border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;margin-bottom:14px;\">';\n  html += '<div style=\"font-size:15px;font-weight:700;color:#0f172a;margin-bottom:10px;\">' + esc(step.title || 'Schritt') + '</div>';\n  if (Array.isArray(step.results)) {\n    for (const result of step.results.slice(0, 6)) {\n      html += '<div style=\"display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:13px;\">';\n      html += '<span style=\"color:#334155;\">' + esc(result.keyword || '') + '</span>';\n      html += '<span style=\"color:' + color + ';font-weight:700;\">' + esc(result.status === 'not_found' ? 'Nicht auf Seite 1' : result.status === 'top3' ? 'Top 3' : 'Position ' + result.position) + '</span>';\n      html += '</div>';\n    }\n  }\n  if (Array.isArray(step.details)) {\n    for (const detail of step.details.slice(0, 6)) {\n      html += '<div style=\"display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:13px;\">';\n      html += '<span style=\"color:#475569;\">' + esc(detail.label || '') + '</span>';\n      html += '<span style=\"color:' + color + ';font-weight:700;\">' + esc(detail.value || '') + '</span>';\n      html += '</div>';\n    }\n  }\n  if (step.summary) {\n    html += '<div style=\"margin-top:12px;background:' + bg + ';border-left:3px solid ' + color + ';border-radius:6px;padding:10px 12px;font-size:13px;line-height:1.6;color:#334155;\">' + esc(step.summary) + '</div>';\n  }\n  html += '</div>';\n}\nhtml += '</div>';\n\nif (story) {\n  html += '<div style=\"padding:0 32px 24px;\">';\n  html += '<h2 style=\"font-size:18px;color:#0f172a;margin:0 0 12px;\">Strategische Einordnung</h2>';\n  for (const paragraph of story.split(/\\n\\n+/).map((item) => item.trim()).filter(Boolean)) {\n    html += '<p style=\"font-size:14px;line-height:1.75;color:#334155;margin:0 0 14px;\">' + esc(paragraph) + '</p>';\n  }\n  html += '</div>';\n}\n\nhtml += '<div style=\"background:#0f172a;color:#fff;padding:32px;text-align:center;\">';\nhtml += '<p style=\"font-size:18px;font-weight:800;margin:0 0 12px;\">N\u00e4chster Schritt: 360\u00b0 Deep-Dive</p>';\nhtml += '<p style=\"font-size:14px;line-height:1.7;color:#cbd5e1;margin:0 0 20px;\">Dieser Report zeigt die Oberfl\u00e4che. Der Deep-Dive zeigt die Ursache, Priorit\u00e4t und den sauberen Hebel.</p>';\nhtml += '<a href=\"https://hasimuener.de/customer-journey-audit/#deep-dive-section\" style=\"background:#ffb020;color:#111827;text-decoration:none;padding:14px 28px;border-radius:8px;font-weight:800;display:inline-block;\">360\u00b0 Deep-Dive starten</a>';\nhtml += '<p style=\"font-size:12px;color:#94a3b8;margin:16px 0 0;\">Kein Sales-Call. 5 Fragen. Pers\u00f6nliche Analyse.</p>';\nhtml += '</div>';\nhtml += '</div></div>';\n\nconst attachmentHtml = '<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Customer Journey Audit</title></head><body>' + html + '</body></html>';\nconst attachmentBuffer = Buffer.from(attachmentHtml, 'utf8');\n\nreturn [{\n  json: {\n    email,\n    subject: siteLabel + ': ' + subjectHook,\n    html,\n    mode,\n    respondToWebhook: mode === 'email_capture',\n    jobId: $json.jobId || '',\n  },\n  binary: {\n    attachment_0: {\n      data: attachmentBuffer.toString('base64'),\n      mimeType: 'text/html',\n      fileName: 'Customer-Journey-Audit-' + fileBase + '.html',\n      fileExtension: 'html',\n    },\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4896,
        -400
      ],
      "id": "822f393a-9d26-4e2b-a84f-f478ae102d3f",
      "name": "Build Email HTML"
    },
    {
      "parameters": {
        "fromEmail": "performance@hasimuener.de",
        "toEmail": "={{ $json.email }}",
        "subject": "={{ $json.subject }}",
        "html": "={{ $json.html }}",
        "options": {
          "attachments": "attachment_0"
        }
      },
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        5136,
        -400
      ],
      "id": "c5990da8-af08-422c-bfe6-0a81c1d35319",
      "name": "Send email",
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.respondToWebhook === true }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5376,
        -400
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00005",
      "name": "IF Email Response Needed?"
    },
    {
      "parameters": {
        "jsCode": "const sd = $getWorkflowStaticData('global');\nconst req = $('URL Validator').first().json;\nconst raw = sd[req.jobId];\n\nif (!raw) {\n  return [{ json: { ok: false, status: 'processing', jobId: req.jobId, message: 'Audit l\u00e4uft noch. Bitte in wenigen Sekunden erneut versuchen.' } }];\n}\n\nlet entry;\ntry {\n  entry = JSON.parse(raw);\n} catch (e) {\n  return [{ json: { ok: false, status: 'error', jobId: req.jobId, error: 'Gespeichertes Audit konnte nicht gelesen werden.' } }];\n}\n\nif (entry.expiresAt && entry.expiresAt < Date.now()) {\n  delete sd[req.jobId];\n  return [{ json: { ok: false, status: 'expired', jobId: req.jobId, error: 'Das Audit-Ergebnis ist abgelaufen.' } }];\n}\n\nif (entry.status !== 'done' || !entry.data) {\n  return [{ json: { ok: false, status: entry.status || 'processing', jobId: req.jobId, message: entry.message || 'Audit l\u00e4uft noch...' } }];\n}\n\nreturn [{\n  json: {\n    ok: true,\n    mode: 'email_capture',\n    status: 'done',\n    jobId: req.jobId,\n    email: req.email,\n    frontendData: entry.data,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        432,
        -240
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00006",
      "name": "Load Stored Audit For Email"
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok === true }}",
              "operation": "isTrue"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        672,
        -240
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00007",
      "name": "IF Stored Audit Ready?"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        912,
        -120
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00008",
      "name": "Respond Email Capture State"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ok: true, status: \"sent\", jobId: $json.jobId || \"\", message: \"Report wird zugestellt. Bitte Posteingang pr\u00fcfen.\" } }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5616,
        -320
      ],
      "id": "1a7c1a10-4b58-4f80-840b-5d1e9fa00009",
      "name": "Respond Email Capture OK"
    },
    {
      "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": [
        -384,
        80
      ],
      "id": "bc8ec8ab-0fda-4426-a2a7-82eeb9511492",
      "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: 'Ung\u00fcltige Job-ID.' } }];\n}\n\nconst raw = sd[jobId];\nif (!raw) {\n  return [{ json: { ok: true, status: 'processing', jobId, message: 'Audit l\u00e4uft 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 || 'Audit fehlgeschlagen.' } }];\n}\n\nreturn [{ json: { ok: true, status: entry.status || 'processing', jobId, message: entry.message || 'Audit l\u00e4uft noch...' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -144,
        80
      ],
      "id": "ad448cf2-625f-46c2-a220-b9982f33b061",
      "name": "Lookup Results"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
  

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

How this works

This workflow automates a thorough audit of your customer journey, analysing each stage from initial touchpoints to conversion to uncover bottlenecks and optimisation opportunities, saving you hours of manual review and boosting conversion rates. It's designed for marketing managers and e-commerce owners who need data-driven insights into funnel performance without deep technical expertise. The key step involves OpenAI processing captured data alongside Jina Reader's web content extraction to generate an intelligent report, emailed directly via the emailSend node for immediate action.

Use this when auditing funnels triggered by form submissions or API events, such as post-purchase surveys, to identify drop-offs in real-time. Avoid it for high-volume traffic sites where processing delays could arise; opt for simpler monitoring tools instead. Common variations include adapting the OpenAI prompts for B2B lead nurturing audits or integrating additional HTTP requests for multi-channel data sources like social media interactions.

About this workflow

Audit Funnel - Customer Journey Audit Runner V2. Uses httpRequest, openAi, emailSend. Webhook trigger; 38 nodes.

Source: https://github.com/Hasim-Uner/meine-wordpress-site-2fe6f514/blob/9995a0e87dbbac8e05b211a48bdb95e7bd12a838/automations/n8n/workflows/audit-funnel__customer-journey-audit__refactor.json — original creator credit. Request a take-down →

More Content & Video workflows → · Browse all categories →

Related workflows

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

Content & Video

Startseiten-Anfragecheck - Live V1. Uses httpRequest, emailSend. Webhook trigger; 26 nodes.

HTTP Request, Email Send
Content & Video

Optimize your WordPress titles and meta descriptions with AI (OpenAI), update them directly in Yoast SEO, log results in Google Sheets, and receive a styled report by email. All from your own n8n inst

HTTP Request, OpenAI, Stop And Error +2
Content & Video

This workflow is designed for developers, content managers, and website administrators managing multilingual WordPress sites. It is highly beneficial for websites built with complex Advanced Custom Fi

HTTP Request, OpenAI, Item Lists
Content & Video

Transform a Google Sheet into an automated content factory! This workflow reads article topics, scrapes source content, uses AI to create original articles, and publishes drafts to WordPress automatic

Google Sheets, HTTP Request, OpenAI +1
Content & Video

This workflow contains community nodes that are only compatible with the self-hosted version of n8n.

HTTP Request, Item Lists, OpenAI +3