{
 "name": "Website city (daniele-michelin.com)",
 "nodes": [
  {
   "parameters": {
    "httpMethod": "POST",
    "path": "your-secret-path",
    "responseMode": "onReceived",
    "options": {}
   },
   "id": "w1",
   "name": "Besucher-Knopf (Webhook)",
   "type": "n8n-nodes-base.webhook",
   "typeVersion": 2,
   "position": [
    0,
    300
   ],
   "webhookId": "5b7c2d1e-9a43-4e1b-8c3e-1c0000000001"
  },
  {
   "parameters": {
    "url": "={{ 'https://YOUR-SITE.example/tools/api/city.php?d=' + encodeURIComponent($json.body.domain) }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website city; +https://daniele-michelin.com/website-stadt/)"
      },
      {
       "name": "X-City-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "options": {
     "timeout": 60000
    }
   },
   "id": "h0",
   "name": "Prüfstelle: Sicherheit, Mail, TLS",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    240,
    300
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "url": "={{ ($json.final_url && /^https?:\\/\\//.test($json.final_url)) ? $json.final_url : 'https://' + $('Besucher-Knopf (Webhook)').first().json.body.domain + '/' }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website city; +https://daniele-michelin.com/website-stadt/)"
      }
     ]
    },
    "options": {
     "timeout": 15000,
     "redirect": {
      "redirect": {
       "followRedirects": false
      }
     },
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "h1",
   "name": "Stadttor: Startseite laden",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    500,
    80
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "url": "={{ ($('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url && /^https?:\\/\\/[^\\/]+/.test($('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url) ? $('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url.match(/^https?:\\/\\/[^\\/]+/)[0] : 'https://' + $('Besucher-Knopf (Webhook)').first().json.body.domain) + '/sitemap.xml' }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website city; +https://daniele-michelin.com/website-stadt/)"
      }
     ]
    },
    "options": {
     "timeout": 15000,
     "redirect": {
      "redirect": {
       "followRedirects": false
      }
     },
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "h2",
   "name": "Katasteramt: sitemap.xml",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    500,
    300
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "url": "={{ ($('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url && /^https?:\\/\\/[^\\/]+/.test($('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url) ? $('Prüfstelle: Sicherheit, Mail, TLS').first().json.final_url.match(/^https?:\\/\\/[^\\/]+/)[0] : 'https://' + $('Besucher-Knopf (Webhook)').first().json.body.domain) + '/sitemap_index.xml' }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website city; +https://daniele-michelin.com/website-stadt/)"
      }
     ]
    },
    "options": {
     "timeout": 15000,
     "redirect": {
      "redirect": {
       "followRedirects": false
      }
     },
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "h3",
   "name": "Katasteramt: sitemap_index.xml",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    500,
    520
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "numberInputs": 3
   },
   "id": "m1",
   "name": "Alles einsammeln",
   "type": "n8n-nodes-base.merge",
   "typeVersion": 3,
   "position": [
    740,
    300
   ]
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Reads the sitemap: a plain <urlset> gives the number of pages directly; a <sitemapindex> lists sub-sitemaps, which the next node\n// fetches (at most 8, same host only). Always returns at least one item so the flow goes on.\nconst pick = n => { try { return $(n).first().json; } catch (e) { return null; } };\nconst req = $('Besucher-Knopf (Webhook)').first().json.body || {};\nconst pr = pick('Prüfstelle: Sicherheit, Mail, TLS') || {};\nconst base = (typeof pr.final_url === 'string' && /^https?:\\/\\/[^\\/]+/.test(pr.final_url)) ? pr.final_url.match(/^https?:\\/\\/[^\\/]+/)[0] : 'https://' + req.domain;\nconst host = base.replace(/^https?:\\/\\//, '').toLowerCase();\nconst bare = h => h.replace(/^www\\./, '');\nconst txt = r => r ? (typeof r.data === 'string' ? r.data : typeof r.body === 'string' ? r.body : '') : '';   // full response: text lands in \"data\"\nconst xml = r => r && Number(r.statusCode) === 200 && /<(urlset|sitemapindex)[\\s>]/i.test(txt(r).slice(0, 5000)) ? txt(r) : '';\nconst locs = s => [...s.matchAll(/<loc>\\s*(?:<!\\[CDATA\\[)?\\s*([^<\\s\\]]+)/gi)].map(m => m[1].replace(/&amp;/g, '&'));\nconst same = u => { const m = String(u).match(/^https?:\\/\\/([^\\/:?#]+)/i); return m && bare(m[1].toLowerCase()) === bare(host) && !/\\.gz($|\\?)/i.test(u); };\nconst sm = pick('Katasteramt: sitemap.xml'), si = pick('Katasteramt: sitemap_index.xml');\nlet src = xml(sm) || xml(si);\nconst plan = { direct: null, children_total: 0, source: '' };\nlet children = [];\nif (src) {\n  plan.source = xml(sm) ? 'sitemap.xml' : 'sitemap_index.xml';\n  if (/<sitemapindex[\\s>]/i.test(src.slice(0, 5000))) {\n    const all = locs(src);\n    plan.children_total = all.length;\n    // at most 8, spread evenly over the index (a representative sample: not only \"posts-1..8\"); pages and districts are scaled up\n    const own = all.filter(same);\n    children = own.length <= 8 ? own : Array.from({ length: 8 }, (_, k) => own[Math.floor(k * own.length / 8)]);\n  } else {\n    plan.direct = (src.match(/<url[\\s>]/gi) || []).length;\n  }\n} else if (sm && Number(sm.statusCode) >= 300 && Number(sm.statusCode) < 400 && sm.headers && sm.headers.location) {\n  // e.g. WordPress: /sitemap.xml -> /wp-sitemap.xml (an index); follow it once, same host only\n  let loc = String(sm.headers.location);\n  if (loc.startsWith('/')) loc = base + loc;\n  if (same(loc)) { children = [loc]; plan.source = 'redirect'; plan.children_total = -1; }\n}\nif (!children.length) return [{ json: { url: '', plan } }];\nreturn children.map(u => ({ json: { url: u, plan } }));"
   },
   "id": "c0",
   "name": "Stadtplan lesen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    960,
    300
   ]
  },
  {
   "parameters": {
    "url": "={{ $json.url }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website city; +https://daniele-michelin.com/website-stadt/)"
      }
     ]
    },
    "options": {
     "timeout": 15000,
     "redirect": {
      "redirect": {
       "followRedirects": false
      }
     },
     "response": {
      "response": {
       "fullResponse": true,
       "neverError": true,
       "responseFormat": "text"
      }
     }
    }
   },
   "id": "h4",
   "name": "Katasteramt: Teilpläne",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1180,
    300
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Measures the city: pages (sitemap), weight and resources of the homepage, technologies, speed, HTTP version, security towers,\n// then writes the prompt for the AI tour guide.\nconst pick = n => { try { return $(n).first().json; } catch (e) { return null; } };\nconst req = $('Besucher-Knopf (Webhook)').first().json.body || {};\nconst lang = ['de', 'en', 'it'].includes(req.lang) ? req.lang : 'en';\nconst domain = String(req.domain || '').toLowerCase();\nconst pr = pick('Prüfstelle: Sicherheit, Mail, TLS') || {};\nconst okPr = pr.status === 'ok';\nconst probe = (okPr && pr.probe) || {};\nconst hp = pick('Stadttor: Startseite laden') || {};\nconst code = Number(hp.statusCode || 0);\nconst txt = r => r ? (typeof r.data === 'string' ? r.data : typeof r.body === 'string' ? r.body : '') : '';   // full response: text lands in \"data\"\nlet html = (code >= 200 && code < 300) ? txt(hp) : '';\nconst bytesOf = s => { try { return Buffer.byteLength(s, 'utf8'); } catch (e) { return unescape(encodeURIComponent(s)).length; } };\nconst weight_kb = Math.round((html ? bytesOf(html) : Number(probe.bytes || 0)) / 1024);\nhtml = html.slice(0, 2000000);\nconst cnt = re => (html.match(re) || []).length;\nconst requests = {\n  scripts: cnt(/<script\\b/gi),\n  styles: cnt(/<link\\b[^>]*\\brel\\s*=\\s*[\"']?[^\"'>]*stylesheet/gi) + cnt(/<style\\b/gi),\n  images: cnt(/<img\\b/gi),\n  links: cnt(/<a\\b[^>]*\\bhref\\s*=/gi),\n};\n\n// ---- pages from the sitemap\nconst plan = (pick('Stadtplan lesen') || {}).plan || {};\nlet pages = plan.direct != null ? plan.direct : null;\nconst bodies = [];                       // the <urlset> texts the structure is read from, with their scale factor\nlet sampleF = 1;\nif (plan.direct != null) {\n  const own = [pick('Katasteramt: sitemap.xml'), pick('Katasteramt: sitemap_index.xml')].map(r => r && Number(r.statusCode) === 200 ? txt(r) : '').find(b => /<urlset[\\s>]/i.test(b.slice(0, 5000)));\n  if (own) bodies.push(own);\n}\nif (plan.children_total) {\n  let sum = 0, okN = 0, nested = 0;\n  for (const it of $('Katasteramt: Teilpläne').all()) {\n    const r = it.json || {};\n    if (Number(r.statusCode) === 200 && txt(r)) {\n      const b = txt(r);\n      if (/<urlset[\\s>]/i.test(b.slice(0, 5000))) { sum += (b.match(/<url[\\s>]/gi) || []).length; okN++; bodies.push(b); }\n      else if (/<sitemapindex[\\s>]/i.test(b.slice(0, 5000))) { nested += (b.match(/<sitemap[\\s>]/gi) || []).length; }\n    }\n  }\n  const total = plan.children_total > 0 ? plan.children_total : okN;\n  sampleF = okN ? Math.max(1, total / okN) : 1;\n  if (okN) pages = Math.round(sum * sampleF);\n}\n\n// ---- structure: the districts (sections) and sub-districts of the site.\n// Source: the URLs of the sitemap(s) read above; without usable sitemap URLs, the same-host <a href> links of the homepage.\n// Path keys: decoded, lower case, accents dropped, only [a-z0-9._~-], no query/fragment, a trailing index.html/.php dropped.\n// Languages: a first segment that is an ISO 639-1 code (or xx-yy) is a language prefix. URLs without a prefix count as one more\n// language (the default language: its code comes from the hreflang alternates, else from <html lang>). The site is multilingual\n// when at least 2 languages each cover >= 15% of the URLs: then they go into \"langs\" and the districts are read from the segment\n// AFTER the prefix, summing the languages. Translated slugs (/fehler/, /en/errors/, /it/errori/) are joined through the hreflang\n// alternates of the sitemap: every URL is filed under the path of its default-language version (or x-default). A single language\n// prefix covering >= 85% of the URLs (e.g. everything under /de/) is also skipped, but \"langs\" then stays empty.\n// Districts: first segment; a top-level page without sub-pages (one path, n = all its language versions) joins \"/\" (home), which\n// also holds the home page. Max 10 sorted by n (desc); beyond 10 the top 9 stay and the rest is merged into \"…\" (always last).\n// sub: max 6 per district, by second segment. depth: deepest path in the district (segments after the language prefix).\n// Sampled sitemaps (max 8 children of an index, at most 20000 URLs read per file) are scaled up like \"pages\".\nconst bare2 = h => String(h || '').toLowerCase().replace(/^www\\./, '');\nconst baseUrl = (typeof pr.final_url === 'string' && /^https?:\\/\\/[^\\/]+/.test(pr.final_url)) ? pr.final_url : 'https://' + domain + '/';\nconst origin = baseUrl.match(/^https?:\\/\\/[^\\/]+/)[0];\nconst hostOf = u => { const m = String(u).match(/^https?:\\/\\/([^\\/:?#]+)/i); return m ? bare2(m[1]) : ''; };\nconst clean = s => { let t = String(s); try { t = decodeURIComponent(t); } catch (e) {}\n  return t.toLowerCase().replace(/ß/g, 'ss').normalize('NFKD').replace(/[̀-ͯ]/g, '')\n    .replace(/[^a-z0-9._~-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-.]+|-+$/g, ''); };\nconst segsOf = u => { const p = String(u).replace(/^https?:\\/\\/[^\\/]+/i, '').replace(/[?#].*$/, '');\n  const s = p.split('/').map(clean).filter(Boolean);\n  if (s.length && /^index\\.(html?|php)$/.test(s[s.length - 1])) s.pop();\n  return s; };\nconst ISO = ('aa ab af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj '\n  + 'fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg '\n  + 'li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg '\n  + 'si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu').split(' ');\nconst isLang = s => /^[a-z]{2}(-[a-z0-9]{2,4})?$/.test(s) && ISO.includes(s.slice(0, 2));\n// entries: { s: path segments, alt: [{ l: hreflang, s }], w: weight }\nconst entries = [];\nlet structure_source = null;\nfor (const b of bodies) {\n  const blocks = b.split(/<url[\\s>]/i).slice(1);\n  const take = blocks.slice(0, 20000);\n  const w = sampleF * (blocks.length / Math.max(1, take.length));\n  for (const blk of take) {\n    const m = blk.match(/<loc>\\s*(?:<!\\[CDATA\\[)?\\s*([^<\\s\\]]+)/i);\n    if (!m) continue;\n    const loc = m[1].replace(/&amp;/g, '&');\n    if (hostOf(loc) !== bare2(hostOf(origin))) continue;\n    const alt = [];\n    for (const lk of blk.match(/<(?:\\w+:)?link\\b[^>]*>/gi) || []) {\n      const hl = (lk.match(/\\bhreflang\\s*=\\s*[\"']([^\"']+)/i) || [])[1], hr = (lk.match(/\\bhref\\s*=\\s*[\"']([^\"']+)/i) || [])[1];\n      if (hl && hr && hostOf(hr.replace(/&amp;/g, '&')) === bare2(hostOf(origin))) alt.push({ l: hl.toLowerCase(), s: segsOf(hr.replace(/&amp;/g, '&')) });\n    }\n    entries.push({ s: segsOf(loc), alt, w });\n  }\n}\nif (entries.length) structure_source = 'sitemap';\nelse if (html) {\n  // no usable sitemap: the internal links of the homepage (same host, no files like .css/.pdf/.jpg), each path once\n  const seen = new Set(['']);\n  entries.push({ s: [], alt: [], w: 1 });\n  for (const m of html.matchAll(/<a\\b[^>]*?\\bhref\\s*=\\s*[\"']?([^\"'\\s>]+)/gi)) {\n    let h = m[1].replace(/&amp;/g, '&').trim();\n    if (!h || /^(#|mailto:|tel:|javascript:|data:)/i.test(h)) continue;\n    if (h.startsWith('//')) h = 'https:' + h;\n    else if (h.startsWith('/')) h = origin + h;\n    else if (!/^[a-z][a-z0-9+.-]*:/i.test(h)) h = baseUrl.replace(/[?#].*$/, '').replace(/[^\\/]*$/, '') + h;\n    if (!/^https?:/i.test(h) || hostOf(h) !== bare2(hostOf(origin))) continue;\n    // resolve ./ and ../\n    const raw = h.replace(/^https?:\\/\\/[^\\/]+/i, '').replace(/[?#].*$/, '').split('/'), out = [];\n    for (const p of raw) { if (p === '..') out.pop(); else if (p !== '.' && p !== '') out.push(p); }\n    const s = segsOf('/' + out.join('/'));\n    if (s.length && /\\.(css|js|mjs|json|xml|txt|ico|png|jpe?g|gif|webp|avif|svg|pdf|zip|gz|mp[34]|webm|woff2?|ttf|webmanifest)$/.test(s[s.length - 1])) continue;\n    const k = s.join('/');\n    if (seen.has(k)) continue;\n    seen.add(k);\n    entries.push({ s, alt: [], w: 1 });\n  }\n  if (entries.length > 1) structure_source = 'links'; else entries.length = 0;\n}\n// languages\nconst lc = {};\nlet tw = 0;\nfor (const e of entries) { const k = e.s.length && isLang(e.s[0]) ? e.s[0] : ''; lc[k] = (lc[k] || 0) + e.w; tw += e.w; }\nconst pref = Object.keys(lc).filter(k => k && lc[k] >= 0.15 * tw).sort((a, b) => lc[b] - lc[a]);\nconst defShare = (lc[''] || 0) >= 0.15 * tw;\nconst multi = pref.length + (defShare ? 1 : 0) >= 2;\nconst strip = multi ? new Set(pref) : new Set(pref.filter(k => lc[k] >= 0.85 * tw));\nlet defLang = '';\nif (multi && defShare) {\n  for (const e of entries) { const d = e.alt.find(a => isLang(a.l) && !(a.s.length && strip.has(a.s[0]))); if (d) { defLang = d.l; break; } }\n  if (!defLang) { const hl = clean((html.match(/<html\\b[^>]*\\blang\\s*=\\s*[\"']?([a-zA-Z-]+)/i) || [])[1] || ''); if (isLang(hl)) defLang = hl; }\n}\nconst langs = multi ? [...new Set([...(defShare && defLang ? [defLang] : []), ...pref])].slice(0, 12) : [];\nconst unpref = s => (s.length && strip.has(s[0]) ? s.slice(1) : s);\nconst keyOf = e => {\n  if (multi && e.alt.length) {\n    const d = e.alt.find(a => !(a.s.length && strip.has(a.s[0]))) || e.alt.find(a => a.l === 'x-default');\n    if (d) return unpref(d.s);\n  }\n  return unpref(e.s);\n};\n// group by first segment\nconst G = {};\nlet max_depth = 0;\nfor (const e of entries) {\n  const k = keyOf(e);\n  const g0 = k.length ? k[0] : '';\n  const g = G[g0] || (G[g0] = { n: 0, depth: 0, paths: new Set(), sub: {} });\n  g.n += e.w; g.depth = Math.max(g.depth, k.length); g.paths.add(k.join('/'));\n  if (k.length > 1) g.sub[k[1]] = (g.sub[k[1]] || 0) + e.w;\n  max_depth = Math.max(max_depth, k.length);\n}\nconst cut = (s, n) => s.slice(0, n).replace(/-+$/, '');\nconst subList = (g0, sub) => Object.entries(sub).sort((a, b) => b[1] - a[1]).slice(0, 6)\n  .map(([k, n]) => ({ path: cut('/' + (g0 ? g0 + '/' : '') + k, 60), label: cut(k, 24), n: Math.max(1, Math.round(n)) }));\nconst home = { path: '/', label: 'home', n: 0, depth: 0, subs: {} };\nlet ds = [];\nfor (const [g0, g] of Object.entries(G)) {\n  if (g0 === '' || (g.paths.size === 1 && g.depth === 1)) { home.n += g.n; home.depth = Math.max(home.depth, g.depth); if (g0) home.subs[g0] = g.n; continue; }\n  ds.push({ path: cut('/' + g0, 60), label: cut(g0, 24), n: g.n, depth: g.depth, sub: subList(g0, g.sub) });\n}\nif (home.n) ds.push({ path: '/', label: 'home', n: home.n, depth: home.depth, sub: subList('', home.subs) });\nds.sort((a, b) => b.n - a.n);\nif (ds.length > 10) {\n  const rest = ds.slice(9);\n  const more = { path: '…', label: { de: 'mehr', en: 'more', it: 'altro' }[lang], n: rest.reduce((a, d) => a + d.n, 0),\n    depth: Math.max(...rest.map(d => d.depth)), sub: rest.slice(0, 6).map(d => ({ path: d.path, label: d.label, n: d.n })) };\n  ds = ds.slice(0, 9).concat([more]);\n}\nconst districts = ds.map(d => ({ path: d.path, label: d.label, n: Math.max(1, Math.round(d.n)), depth: d.depth,\n  sub: d.sub.map(x => ({ path: x.path, label: x.label, n: Math.max(1, Math.round(x.n)) })) }));\n\n// ---- technologies from headers and HTML\nconst hh = hp.headers || {};\nconst H = k => String(hh[k] || '');\nconst server = H('server') || probe.server || '';\nconst powered = H('x-powered-by') || probe.powered || '';\nconst gen = ((html.match(/<meta[^>]+name=[\"']generator[\"'][^>]*content=[\"']([^\"']+)/i) || html.match(/<meta[^>]+content=[\"']([^\"']+)[\"'][^>]*name=[\"']generator[\"']/i) || [])[1] || '');\nconst cookies = [].concat(hh['set-cookie'] || []).join(' ');\nconst T = [];\nconst add = (c, n) => { if (c && !T.includes(n)) T.push(n); };\nadd(/cloudflare/i.test(server) || H('cf-ray'), 'Cloudflare');\nadd(H('x-vercel-id') || /vercel/i.test(server), 'Vercel');\nadd(/netlify/i.test(server) || H('x-nf-request-id'), 'Netlify');\nadd(H('x-amz-cf-id'), 'CloudFront');\nadd(/fastly/i.test(server) || /^cache-/i.test(H('x-served-by')), 'Fastly');\nadd(/github\\.com/i.test(server), 'GitHub Pages');\nadd(/openresty/i.test(server), 'OpenResty');\nadd(/nginx/i.test(server), 'nginx');\nadd(/apache/i.test(server), 'Apache');\nadd(/litespeed/i.test(server), 'LiteSpeed');\nadd(/microsoft-iis/i.test(server), 'IIS');\nadd(/caddy/i.test(server), 'Caddy');\nadd(/wordpress/i.test(gen) || /\\/wp-(content|includes)\\//.test(html), 'WordPress');\nadd(/woocommerce/i.test(gen) || /woocommerce/i.test(html), 'WooCommerce');\nadd(H('x-shopify-stage') || /cdn\\.shopify\\.com/i.test(html), 'Shopify');\nadd(H('x-wix-request-id') || /static\\.wixstatic\\.com|wix\\.com website builder/i.test(html + gen), 'Wix');\nadd(/squarespace/i.test(gen) || /static1\\.squarespace\\.com/i.test(html), 'Squarespace');\nadd(/webflow/i.test(gen) || /data-wf-page=/i.test(html), 'Webflow');\nadd(/joomla/i.test(gen), 'Joomla');\nadd(/drupal/i.test(gen) || H('x-drupal-cache') || H('x-drupal-dynamic-cache') || /drupal-settings-json/.test(html), 'Drupal');\nadd(/typo3/i.test(gen), 'TYPO3');\nadd(/shopware/i.test(gen) || /sw-context-token|sw-cache-hash/i.test(cookies), 'Shopware');\nadd(/Magento_|mage\\/cookies/.test(html), 'Magento');\nadd(/ghost/i.test(gen), 'Ghost');\nadd(/hugo/i.test(gen), 'Hugo');\nadd(/jekyll/i.test(gen), 'Jekyll');\nadd(/gatsby/i.test(gen) || /id=\"___gatsby\"/.test(html), 'Gatsby');\nadd(/next\\.js/i.test(powered) || /\\/_next\\/static\\//.test(html), 'Next.js');\nadd(/\\/_nuxt\\/|window\\.__NUXT__/.test(html), 'Nuxt');\nadd(/astro/i.test(gen) || /astro-island/.test(html), 'Astro');\nadd(/php/i.test(powered) || /PHPSESSID/.test(cookies), 'PHP');\nadd(/express/i.test(powered), 'Express');\nadd(/asp\\.net/i.test(powered) || H('x-aspnet-version') || /ASP\\.NET_SessionId/.test(cookies), 'ASP.NET');\nadd(/\\sng-version=/.test(html), 'Angular');\nadd(/data-reactroot|react-dom(\\.production)?(\\.min)?\\.js/.test(html), 'React');\nadd(/\\sdata-v-[0-9a-f]{8}|\\/vue(\\.global|\\.runtime)?(\\.prod|\\.min)?\\.js/.test(html), 'Vue');\nadd(/jquery(-\\d[\\d.]*)?(\\.min)?\\.js/i.test(html), 'jQuery');\nadd(/bootstrap(\\.bundle)?(\\.min)?\\.(css|js)/i.test(html), 'Bootstrap');\nadd(/googletagmanager\\.com|google-analytics\\.com/i.test(html), 'Google Analytics');\nadd(/matomo\\.(js|php)|piwik\\.(js|php)/i.test(html), 'Matomo');\nconst tech = T.slice(0, 8);\n\n// ---- speed, protocol, scores (from the Prüfstelle)\nconst ttfb_ms = Math.round(Number(probe.ttfb_ms || 0));\nconst total_ms = Math.round(Number(probe.total_ms || 0));\nconst http = ['1.1', '2', '3'].includes(probe.http) ? probe.http : '1.1';\nconst https = okPr ? !!pr.https : true;\nconst security = { score: okPr ? pr.security.score : 0,\n  headers: Object.assign({ hsts: false, csp: false, xcto: false, xfo: false, referrer: false, permissions: false }, okPr ? pr.security.headers : {}) };\nconst mail = { score: okPr ? pr.mail.score : 0, spf: okPr ? !!pr.mail.spf : false, dmarc: okPr ? pr.mail.dmarc : null };\nconst tls = { score: okPr ? pr.tls.score : 0, days_left: okPr ? pr.tls.days_left : null, proto: okPr ? pr.tls.proto : '' };\nconst overall = okPr ? pr.overall : 0;\nconst grade = okPr ? pr.grade : 'F';\n\n// ---- the name of the city: the most telling part of the domain (daniele-michelin.com -> Michelin)\nconst lab = domain.replace(/^www\\./, '').split('.');\nlet sld = lab.length >= 2 ? lab[lab.length - 2] : lab[0];\nif (lab.length >= 3 && ['co', 'com', 'org', 'net', 'gov', 'ac', 'or', 'ne', 'gv'].includes(sld)) sld = lab[lab.length - 3];\nconst parts = sld.split('-').filter(Boolean);\nlet core = parts.length > 1 && parts[parts.length - 1].length >= 4 ? parts[parts.length - 1] : parts.join('-');\ncore = core.charAt(0).toUpperCase() + core.slice(1, 30);\nconst title = { de: core + '-Stadt', en: core + ' City', it: 'Città di ' + core }[lang];\n\nconst city = { id: req.id, lang, domain, title, pages, weight_kb, requests, ttfb_ms, total_ms, http, https, tech, security, mail, tls, grade, overall,\n  langs, max_depth, districts, structure_source };\nconst towers = Object.values(security.headers).filter(Boolean).length;\nconst facts = { city: title, domain, pages_in_sitemap: pages === null ? 'no sitemap found' : pages, homepage_weight_kb: weight_kb,\n  homepage_scripts: requests.scripts, homepage_stylesheets: requests.styles, homepage_images: requests.images, homepage_links: requests.links,\n  time_to_first_byte_ms: ttfb_ms, total_load_ms: total_ms, http_version: http, https,\n  technologies: tech, wall_towers_standing_of_6: towers, security_headers_present: security.headers, security_score: security.score,\n  mail_seal: { spf: mail.spf, dmarc_policy: mail.dmarc || 'missing', score: mail.score }, certificate_days_left: tls.days_left, tls_version: tls.proto || 'unknown',\n  overall_score: overall, grade,\n  districts: districts.slice(0, 6).map(d => ({ name: d.label, pages: d.n })), languages: langs };\nconst LANG = { de: 'German, informal \"du\"', en: 'English', it: 'Italian, informal \"tu\"' }[lang];\nconst prompt = 'You are the city guide of \"Website City\": you show visitors a website as if it were a town. '\n  + `Write in ${LANG}. Facts about the town (use ONLY these numbers, never invent any other number or fact): ${JSON.stringify(facts)}. `\n  + 'Picture: pages in the sitemap = houses; homepage weight = how heavy the buildings are; scripts = street musicians; images = statues; '\n  + 'time to first byte = how long the city gate takes to open; the 6 security headers = towers of the city wall (missing ones are gaps); '\n  + 'site sections = districts of the town (name and number of pages each); '\n  + 'SPF and DMARC = the seal of the post office; certificate days left = how long the city charter is still valid; technologies = the building companies. '\n  + 'Answer with JSON only: {\"guide\":\"\"}. guide: 3-4 sentences, max 380 characters in total, a cheerful, ironic and affectionate tour, '\n  + 'quoting 3-4 of the numbers exactly. Weaknesses are told with gentle humour, never insults, never mean or alarming. '\n  + 'Do not repeat the domain more than once. No hashtags, no emojis, no markdown.';\nreturn [{ json: { city,\n  body: { model: 'openai/gpt-oss-120b', temperature: 0.8, max_tokens: 3000, reasoning_effort: 'low', response_format: { type: 'json_object' },\n          messages: [{ role: 'user', content: prompt }] } } }];"
   },
   "id": "c1",
   "name": "Stadt vermessen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1400,
    300
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://openai.inference.de-txl.ionos.com/v1/chat/completions",
    "authentication": "genericCredentialType",
    "genericAuthType": "httpHeaderAuth",
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json.body) }}",
    "options": {
     "timeout": 60000
    }
   },
   "id": "a1",
   "name": "Stadtführer (KI)",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1620,
    300
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpHeaderAuth": {
     "id": "",
     "name": "YOUR OpenAI-compatible API key (Header Auth: Authorization = Bearer …)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Reads the AI tour; if it fails or is too long, the guide falls back to a template built from the same numbers.\nconst d = $('Stadt vermessen').first().json.city;\nlet g = '';\ntry { const m = String($input.first().json.choices[0].message.content).match(/\\{[\\s\\S]*\\}/); g = String(JSON.parse(m[0]).guide || ''); } catch (e) {}\ng = g.replace(/[*_#`]/g, '').replace(/\\s+/g, ' ').trim();\nif (g.length > 380) { const cut = g.slice(0, 380); const p = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? ')); g = p > 150 ? cut.slice(0, p + 1) : ''; }\nconst towers = Object.values(d.security.headers).filter(Boolean).length;\nconst fmt = n => String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, d.lang === 'en' ? ',' : '.');\nconst F = {\n  de: `Willkommen in ${d.title}! ${d.pages != null ? `${fmt(d.pages)} Häuser stehen im Stadtplan` : 'Einen Stadtplan gibt es nicht, man verläuft sich hier charmant'}, die Startseite wiegt ${fmt(d.weight_kb)} KB und das Stadttor öffnet nach ${d.ttfb_ms} ms. `\n    + `${towers} von 6 Mauertürmen stehen${towers < 6 ? ', die anderen machen wohl gerade Pause' : ', die Wache ist vollzählig'}. Gesamtnote ${d.grade}.`,\n  en: `Welcome to ${d.title}! ${d.pages != null ? `${fmt(d.pages)} houses are on the city map` : 'There is no city map, so you get lost in style'}, the homepage weighs ${fmt(d.weight_kb)} KB and the gate opens after ${d.ttfb_ms} ms. `\n    + `${towers} of 6 wall towers are standing${towers < 6 ? ', the others seem to be on a coffee break' : ', the guard is complete'}. Overall grade ${d.grade}.`,\n  it: `Benvenuti a ${d.title}! ${d.pages != null ? `Sulla mappa ci sono ${fmt(d.pages)} case` : 'Una mappa non c\\'è, ci si perde con stile'}, la homepage pesa ${fmt(d.weight_kb)} KB e la porta si apre dopo ${d.ttfb_ms} ms. `\n    + `${towers} torri su 6 sono in piedi${towers < 6 ? ', le altre sono in pausa caffè' : ', la guardia è al completo'}. Voto finale ${d.grade}.` }[d.lang];\nif (g.length < 60) g = F;\nreturn [{ json: { ...d, guide: g.slice(0, 400) } }];"
   },
   "id": "c2",
   "name": "Führung prüfen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1840,
    300
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://YOUR-SITE.example/city-ingest.php",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "X-City-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json) }}",
    "options": {
     "timeout": 60000
    }
   },
   "id": "p1",
   "name": "Stadt an die Seite",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2060,
    300
   ]
  }
 ],
 "connections": {
  "Besucher-Knopf (Webhook)": {
   "main": [
    [
     {
      "node": "Prüfstelle: Sicherheit, Mail, TLS",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Prüfstelle: Sicherheit, Mail, TLS": {
   "main": [
    [
     {
      "node": "Stadttor: Startseite laden",
      "type": "main",
      "index": 0
     },
     {
      "node": "Katasteramt: sitemap.xml",
      "type": "main",
      "index": 0
     },
     {
      "node": "Katasteramt: sitemap_index.xml",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Stadttor: Startseite laden": {
   "main": [
    [
     {
      "node": "Alles einsammeln",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Katasteramt: sitemap.xml": {
   "main": [
    [
     {
      "node": "Alles einsammeln",
      "type": "main",
      "index": 1
     }
    ]
   ]
  },
  "Katasteramt: sitemap_index.xml": {
   "main": [
    [
     {
      "node": "Alles einsammeln",
      "type": "main",
      "index": 2
     }
    ]
   ]
  },
  "Alles einsammeln": {
   "main": [
    [
     {
      "node": "Stadtplan lesen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Stadtplan lesen": {
   "main": [
    [
     {
      "node": "Katasteramt: Teilpläne",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Katasteramt: Teilpläne": {
   "main": [
    [
     {
      "node": "Stadt vermessen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Stadt vermessen": {
   "main": [
    [
     {
      "node": "Stadtführer (KI)",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Stadtführer (KI)": {
   "main": [
    [
     {
      "node": "Führung prüfen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Führung prüfen": {
   "main": [
    [
     {
      "node": "Stadt an die Seite",
      "type": "main",
      "index": 0
     }
    ]
   ]
  }
 },
 "active": false,
 "settings": {
  "executionOrder": "v1",
  "timezone": "Europe/Berlin",
  "saveDataSuccessExecution": "none",
  "saveDataErrorExecution": "all"
 },
 "pinData": {}
}