{
 "name": "Website DNA (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": "7d1e4a2b-3c5f-4e8a-9b6d-0d0a00000001"
  },
  {
   "parameters": {
    "url": "={{ 'https://YOUR-SITE.example/tools/api/dna.php?d=' + encodeURIComponent($json.body.domain) }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website DNA; +https://daniele-michelin.com/website-dna/)"
      },
      {
       "name": "X-Dna-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "options": {
     "timeout": 90000
    }
   },
   "id": "h0",
   "name": "Probe entnehmen",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    240,
    300
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Sequences the homepage: every script, stylesheet, font, iframe and image with its host (own or third party), known services,\n// libraries with version (file name, CDN path, ?ver=, banner of the own JS files, headers, meta generator), cookies.\n// Nothing is downloaded here: the probe already loaded the homepage and max 5 JS files of the same host. Builds the OSV query (npm).\nconst req = $('Besucher-Knopf (Webhook)').first().json.body || {};\nconst domain = String(req.domain || '').toLowerCase();\nconst pb = $input.first().json || {};\nconst ok = pb.status === 'ok' && Number(pb.code) >= 200 && Number(pb.code) < 400;\nconst html = ok ? String(pb.html || '').slice(0, 2000000) : '';\nconst finalUrl = /^https?:\\/\\/[^\\/]+/.test(pb.final_url || '') ? pb.final_url : 'https://' + domain + '/';\nconst fhost = String(pb.host || domain).toLowerCase();\n\n// own = same registrable domain as the visited site (www., cdn. and other subdomains included)\nconst reg = h => { const l = String(h).toLowerCase().replace(/\\.$/, '').split('.'); if (l.length <= 2) return l.join('.');\n  const s = l[l.length - 2]; return (['co', 'com', 'org', 'net', 'gov', 'ac', 'or', 'ne', 'gv', 'edu'].includes(s) && l[l.length - 1].length === 2 ? l.slice(-3) : l.slice(-2)).join('.'); };\nconst OWN = new Set([reg(domain), reg(fhost)]);\nconst third = h => !OWN.has(reg(h));\n\nconst dec = s => String(s).replace(/&amp;|&#0*38;|&#x0*26;/gi, '&').replace(/&quot;/g, '\"').replace(/&#0*39;|&apos;/g, \"'\").trim();\nconst attr = (tag, name) => { const m = tag.match(new RegExp('\\\\s' + name + '\\\\s*=\\\\s*(?:\"([^\"]*)\"|\\'([^\\']*)\\'|([^\\\\s\"\\'>]+))', 'i'));\n  return m ? dec(m[1] ?? m[2] ?? m[3] ?? '') : ''; };\nconst abs = r => { if (!r || /^(data|javascript|mailto|tel|blob|about):/i.test(r) || r.startsWith('#') || r.includes('{{')) return null;\n  // no URL class in the n8n task-runner sandbox: resolve absolute, protocol-relative, root-relative and relative links by hand\n  const base = finalUrl.match(/^(https?:)\\/\\/([^\\/?#]+)([^?#]*)/i) || [];\n  let s = String(r).trim(), m;\n  if (s.startsWith('//')) s = (base[1] || 'https:') + s;\n  else if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) {\n    const dir = (base[3] || '/').replace(/[^\\/]*$/, '') || '/';\n    s = (base[1] || 'https:') + '//' + (base[2] || domain) + (s.startsWith('/') ? s : dir + s);\n  }\n  if (!(m = s.match(/^(https?:)\\/\\/([^\\/?#:@]+)(?::\\d+)?([^?#]*)(\\?[^#]*)?/i))) return null;\n  const hostname = m[2].toLowerCase(), pathname = m[3] || '/', search = m[4] || '';\n  if (!hostname.includes('.')) return null;\n  const q = {}; search.slice(1).split('&').forEach(kv => { const i = kv.indexOf('='); const k = i < 0 ? kv : kv.slice(0, i);\n    if (k && !(k in q)) { try { q[k] = decodeURIComponent((i < 0 ? '' : kv.slice(i + 1)).replace(/\\+/g, ' ')); } catch (e) { q[k] = ''; } } });\n  return { protocol: m[1].toLowerCase(), hostname, pathname, search, href: m[1].toLowerCase() + '//' + hostname + pathname + search,\n           searchParams: { get: k => (k in q ? q[k] : null) } }; };\n\n// ---- known services (host -> name, category)\nconst SVC = [\n  [/(^|\\.)googletagmanager\\.com$/, 'Google Tag Manager', 'analytics'], [/(^|\\.)(google-analytics\\.com|analytics\\.google\\.com)$/, 'Google Analytics', 'analytics'],\n  [/(^|\\.)(doubleclick\\.net|googlesyndication\\.com|googleadservices\\.com|adservice\\.google\\.[a-z.]+)$/, 'Google Ads', 'ads'],\n  [/^fonts\\.(googleapis|gstatic)\\.com$/, 'Google Fonts', 'fonts'], [/(^|\\.)typekit\\.net$/, 'Adobe Fonts', 'fonts'], [/^fonts\\.bunny\\.net$/, 'Bunny Fonts', 'fonts'],\n  [/(^|\\.)fontawesome\\.com$/, 'Font Awesome', 'fonts'], [/(^|\\.)fonts\\.net$/, 'Fonts.com', 'fonts'],\n  [/^cdnjs\\.cloudflare\\.com$/, 'cdnjs', 'cdn'], [/^cdn\\.jsdelivr\\.net$/, 'jsDelivr', 'cdn'], [/^unpkg\\.com$/, 'unpkg', 'cdn'], [/^code\\.jquery\\.com$/, 'jQuery CDN', 'cdn'],\n  [/^ajax\\.googleapis\\.com$/, 'Google Hosted Libraries', 'cdn'], [/(^|\\.)bootstrapcdn\\.com$/, 'BootstrapCDN', 'cdn'], [/(^|\\.)cloudfront\\.net$/, 'Amazon CloudFront', 'cdn'],\n  [/(^|\\.)(akamaihd|akamaized)\\.net$/, 'Akamai', 'cdn'], [/(^|\\.)fastly\\.net$/, 'Fastly', 'cdn'], [/^cdn\\.shopify\\.com$/, 'Shopify CDN', 'cdn'],\n  [/(^|\\.)wixstatic\\.com$/, 'Wix', 'cdn'], [/(^|\\.)squarespace(-cdn)?\\.com$/, 'Squarespace', 'cdn'], [/(^|\\.)wp\\.com$/, 'WordPress.com CDN', 'cdn'],\n  [/^static\\.cloudflareinsights\\.com$/, 'Cloudflare Web Analytics', 'analytics'], [/^challenges\\.cloudflare\\.com$/, 'Cloudflare Turnstile', 'captcha'],\n  [/(^|\\.)(youtube\\.com|youtube-nocookie\\.com|ytimg\\.com)$/, 'YouTube', 'video'], [/(^|\\.)(vimeo\\.com|vimeocdn\\.com)$/, 'Vimeo', 'video'],\n  [/(^|\\.)(wistia\\.com|wistia\\.net)$/, 'Wistia', 'video'], [/(^|\\.)jwplayer\\.com$|(^|\\.)jwpcdn\\.com$/, 'JW Player', 'video'],\n  [/^connect\\.facebook\\.net$|(^|\\.)facebook\\.com$/, 'Meta (Facebook)', 'ads'], [/(^|\\.)instagram\\.com$|(^|\\.)cdninstagram\\.com$/, 'Instagram', 'social'],\n  [/(^|\\.)(twitter\\.com|twimg\\.com|x\\.com)$/, 'X (Twitter)', 'social'], [/(^|\\.)(licdn\\.com|linkedin\\.com)$/, 'LinkedIn', 'ads'],\n  [/(^|\\.)tiktok\\.com$/, 'TikTok', 'ads'], [/(^|\\.)pinterest\\.com$|(^|\\.)pinimg\\.com$/, 'Pinterest', 'social'], [/^bat\\.bing\\.com$/, 'Microsoft Ads', 'ads'],\n  [/(^|\\.)hotjar\\.com$/, 'Hotjar', 'analytics'], [/(^|\\.)clarity\\.ms$/, 'Microsoft Clarity', 'analytics'], [/(^|\\.)plausible\\.io$/, 'Plausible', 'analytics'],\n  [/(^|\\.)matomo\\.cloud$/, 'Matomo Cloud', 'analytics'], [/(^|\\.)segment\\.(com|io)$/, 'Segment', 'analytics'], [/(^|\\.)mc\\.yandex\\.ru$|(^|\\.)yandex\\.ru$/, 'Yandex Metrica', 'analytics'],\n  [/(^|\\.)(hs-scripts|hs-analytics|hsforms|hubspot)\\.(com|net)$/, 'HubSpot', 'analytics'], [/(^|\\.)mxpnl\\.com$|(^|\\.)mixpanel\\.com$/, 'Mixpanel', 'analytics'],\n  [/(^|\\.)(intercom\\.io|intercomcdn\\.com)$/, 'Intercom', 'chat'], [/(^|\\.)tawk\\.to$/, 'tawk.to', 'chat'], [/(^|\\.)crisp\\.chat$/, 'Crisp', 'chat'],\n  [/(^|\\.)driftt?\\.com$/, 'Drift', 'chat'], [/(^|\\.)zdassets\\.com$|(^|\\.)zopim\\.com$/, 'Zendesk', 'chat'], [/(^|\\.)tidio\\.co$/, 'Tidio', 'chat'],\n  [/(^|\\.)freshchat\\.com$/, 'Freshchat', 'chat'], [/(^|\\.)livechatinc\\.com$/, 'LiveChat', 'chat'], [/(^|\\.)userlike\\.com$|(^|\\.)userlike-cdn/, 'Userlike', 'chat'],\n  [/^maps\\.(googleapis|google|gstatic)\\.com$/, 'Google Maps', 'maps'], [/(^|\\.)mapbox\\.com$/, 'Mapbox', 'maps'], [/(^|\\.)openstreetmap\\.org$/, 'OpenStreetMap', 'maps'],\n  [/^www\\.google\\.com$|^www\\.gstatic\\.com$|(^|\\.)recaptcha\\.net$/, 'Google (reCAPTCHA)', 'captcha'], [/(^|\\.)hcaptcha\\.com$/, 'hCaptcha', 'captcha'],\n  [/(^|\\.)cookiebot\\.com$/, 'Cookiebot', 'consent'], [/(^|\\.)cookielaw\\.org$|(^|\\.)onetrust\\.com$/, 'OneTrust', 'consent'], [/(^|\\.)usercentrics\\.eu$/, 'Usercentrics', 'consent'],\n  [/(^|\\.)iubenda\\.com$/, 'iubenda', 'consent'], [/(^|\\.)consentmanager\\.net$/, 'consentmanager', 'consent'], [/(^|\\.)cookieyes\\.com$/, 'CookieYes', 'consent'],\n  [/(^|\\.)stripe\\.com$/, 'Stripe', 'payment'], [/(^|\\.)(paypal\\.com|paypalobjects\\.com)$/, 'PayPal', 'payment'],\n  [/(^|\\.)gravatar\\.com$/, 'Gravatar', 'other'], [/(^|\\.)sentry(-cdn)?\\.io$|(^|\\.)sentry-cdn\\.com$/, 'Sentry', 'analytics'], [/(^|\\.)newrelic\\.com$|(^|\\.)nr-data\\.net$/, 'New Relic', 'analytics'],\n];\nconst svcOf = h => { for (const [re, n, c] of SVC) if (re.test(h)) return { name: n, cat: c }; return null; };\n\n// ---- resources from the HTML (comments removed; JSON-LD and templates are not scripts)\nconst body = html.replace(/<!--[\\s\\S]*?-->/g, '');\nconst res = [], seen = new Set();\nconst add = (t, r, via) => { const u = abs(r); if (!u) return; const key = t + ' ' + u.href.replace(/#.*$/, ''); if (seen.has(key)) return; seen.add(key);\n  res.push({ t, u, host: u.hostname.toLowerCase().replace(/\\.$/, ''), via: via || '' }); };\nconst FONTCSS = /fonts\\.googleapis\\.com|use\\.typekit\\.net|p\\.typekit\\.net|fonts\\.bunny\\.net|use\\.fontawesome\\.com|kit\\.fontawesome\\.com|fast\\.fonts\\.net/i;\nfor (const m of body.matchAll(/<script\\b[^>]*>([\\s\\S]*?)<\\/script\\s*>/gi)) {\n  const tag = m[0].slice(0, m[0].indexOf('>') + 1), type = attr(tag, 'type').toLowerCase();\n  const src = attr(tag, 'src') || attr(tag, 'data-src');\n  if (src) { add('script', src); continue; }\n  if (type && !/javascript|module|text\\/plain/.test(type)) continue;          // JSON-LD, templates\n  // inline code: only hosts of known services count (e.g. a tag manager snippet that loads gtm.js)\n  for (const u of (m[1] || '').matchAll(/(?:https?:)?\\/\\/([a-z0-9-]+(?:\\.[a-z0-9-]+)+)(\\/[^\\s\"'`)<>\\\\]*)?/gi)) {\n    const h = u[1].toLowerCase();\n    if (third(h) && svcOf(h)) add('script', 'https://' + h + (u[2] || '/'), 'inline');\n  }\n}\nfor (const m of body.matchAll(/<link\\b[^>]*>/gi)) {\n  const rel = attr(m[0], 'rel').toLowerCase(), href = attr(m[0], 'href'), as = attr(m[0], 'as').toLowerCase();\n  if (!href) continue;\n  if (/\\bstylesheet\\b/.test(rel)) add(FONTCSS.test(href) ? 'font' : 'css', href);\n  else if (/\\bmodulepreload\\b/.test(rel)) add('script', href, 'preload');\n  else if (/\\b(preload|prefetch)\\b/.test(rel)) { if (as === 'font') add('font', href, 'preload'); else if (as === 'script') add('script', href, 'preload');\n    else if (as === 'style') add('css', href, 'preload'); else if (as === 'image') add('img', href, 'preload'); }\n  else if (/\\b(preconnect|dns-prefetch)\\b/.test(rel)) add('hint', href);\n}\nfor (const m of body.matchAll(/<iframe\\b[^>]*>/gi)) { const s = attr(m[0], 'src') || attr(m[0], 'data-src') || attr(m[0], 'data-lazy-src'); if (s) add('iframe', s); }\nfor (const m of body.matchAll(/<(img|source|video|audio)\\b[^>]*>/gi)) {\n  const k = m[1].toLowerCase();\n  let s = attr(m[0], 'src') || attr(m[0], 'data-src') || attr(m[0], 'data-lazy-src');\n  if (!s) { const ss = attr(m[0], 'srcset') || attr(m[0], 'data-srcset'); s = ss.split(',')[0].trim().split(/\\s+/)[0]; }\n  if (k === 'video' && !s) s = attr(m[0], 'poster');\n  if (s) add(k === 'img' || (k === 'source' && /srcset/i.test(m[0])) ? 'img' : 'media', s);\n}\nfor (const m of body.matchAll(/<style\\b[^>]*>([\\s\\S]*?)<\\/style\\s*>/gi)) {\n  for (const u of m[1].matchAll(/@import\\s+(?:url\\()?\\s*[\"']?([^\"')\\s;]+)/gi)) add(FONTCSS.test(u[1]) ? 'font' : 'css', u[1]);\n  for (const ff of m[1].matchAll(/@font-face\\s*\\{[^}]*\\}/gi)) for (const u of ff[0].matchAll(/url\\(\\s*[\"']?([^\"')]+)/gi)) add('font', u[1]);\n}\n\n// ---- libraries with version\nconst LIB = {\n  'jquery': ['jQuery', 'jquery'], 'jquery.slim': ['jQuery', 'jquery'], 'jquery-migrate': ['jQuery Migrate', 'jquery-migrate'],\n  'jquery-ui': ['jQuery UI', 'jquery-ui'], 'jquery.ui': ['jQuery UI', 'jquery-ui'], 'bootstrap': ['Bootstrap', 'bootstrap'], 'bootstrap.bundle': ['Bootstrap', 'bootstrap'],\n  'popper': ['Popper', 'popper.js'], 'lodash': ['Lodash', 'lodash'], 'underscore': ['Underscore', 'underscore'], 'underscore-min': ['Underscore', 'underscore'],\n  'moment': ['Moment.js', 'moment'], 'moment-with-locales': ['Moment.js', 'moment'], 'angular': ['AngularJS', 'angular'], 'react': ['React', 'react'],\n  'react-dom': ['React DOM', 'react-dom'], 'vue': ['Vue', 'vue'], 'handlebars': ['Handlebars', 'handlebars'], 'mustache': ['Mustache', 'mustache'],\n  'd3': ['D3', 'd3'], 'chart': ['Chart.js', 'chart.js'], 'three': ['three.js', 'three'], 'gsap': ['GSAP', 'gsap'], 'tweenmax': ['GSAP', 'gsap'],\n  'swiper': ['Swiper', 'swiper'], 'swiper-bundle': ['Swiper', 'swiper'], 'slick': ['Slick', 'slick-carousel'], 'select2': ['Select2', 'select2'],\n  'axios': ['axios', 'axios'], 'purify': ['DOMPurify', 'dompurify'], 'dompurify': ['DOMPurify', 'dompurify'], 'alpine': ['Alpine.js', 'alpinejs'],\n  'alpinejs': ['Alpine.js', 'alpinejs'], 'htmx': ['htmx', 'htmx.org'], 'lazysizes': ['lazysizes', 'lazysizes'], 'modernizr': ['Modernizr', 'modernizr'],\n  'isotope': ['Isotope', 'isotope-layout'], 'masonry': ['Masonry', 'masonry-layout'], 'jquery.fancybox': ['fancyBox', '@fancyapps/fancybox'],\n  'fancybox': ['fancyBox', '@fancyapps/ui'], 'lightbox': ['Lightbox2', 'lightbox2'], 'video': ['Video.js', 'video.js'], 'hls': ['hls.js', 'hls.js'],\n  'prism': ['Prism', 'prismjs'], 'highlight': ['highlight.js', 'highlight.js'], 'font-awesome': ['Font Awesome', 'font-awesome'],\n  'fontawesome': ['Font Awesome', '@fortawesome/fontawesome-free'], 'all': null, 'animate': ['Animate.css', 'animate.css'], 'aos': ['AOS', 'aos'],\n  'jquery.validate': ['jQuery Validation', 'jquery-validation'], 'js.cookie': ['js-cookie', 'js-cookie'], 'owl.carousel': ['Owl Carousel', 'owl.carousel'],\n  'tinymce': ['TinyMCE', 'tinymce'], 'ckeditor': ['CKEditor', 'ckeditor4'], 'pdf': ['PDF.js', 'pdfjs-dist'], 'leaflet': ['Leaflet', 'leaflet'],\n  'sweetalert2': ['SweetAlert2', 'sweetalert2'], 'jszip': ['JSZip', 'jszip'], 'datatables': ['DataTables', 'datatables.net'], 'jquery.datatables': ['DataTables', 'datatables.net'],\n};\nconst SUFFIX = /([.-](min|slim|bundle|prod|production|esm|umd|pkgd|full|global|runtime|browser|dist|js|css))+$/;\nconst VER = '(\\\\d+\\\\.\\\\d+(?:\\\\.\\\\d+)?(?:-[a-z]+\\\\.?\\\\d*)?)';\nconst libs = [];\nconst putLib = (l) => {\n  const old = libs.find(x => x.pkg === l.pkg && (x.url === l.url || !x.version || !l.version || x.version === l.version));\n  if (old) { if (!old.version && l.version) Object.assign(old, { version: l.version, src: l.src }); return; }\n  if (libs.length < 20) libs.push(l);\n};\nfor (const r of res) {\n  if (!['script', 'css', 'font'].includes(r.t)) continue;\n  let path = ''; try { path = decodeURIComponent(r.u.pathname); } catch (e) { path = r.u.pathname; }\n  const segs = path.toLowerCase().split('/').filter(Boolean);\n  const file = (segs.pop() || '').replace(/\\.(m?js|css)$/, '');\n  let base = file, version = null, src = 'file', m;\n  if ((m = file.match(new RegExp('^(.*?)[-_.@]v?' + VER + '((?:[.-](?:min|slim|bundle|prod|production|esm|umd|pkgd|full|global|runtime))*)$')))) { base = m[1] + m[3]; version = m[2]; }\n  base = base.replace(SUFFIX, '');\n  let pkgHint = null;\n  segs.forEach((s, i) => {\n    let q;\n    if ((q = s.match(new RegExp('^(@?[a-z0-9._-]+)@v?' + VER + '$')))) { pkgHint = q[1]; if (!version) { version = q[2]; src = 'cdn'; } }\n    else if (new RegExp('^v?' + VER + '$').test(s) && i > 0 && !version) { version = s.replace(/^v/, ''); pkgHint = segs[i - 1]; src = 'cdn'; }\n  });\n  if (segs.includes('@popperjs')) pkgHint = '@popperjs/core';\n  let key = LIB[base] !== undefined ? base : (pkgHint && LIB[pkgHint.replace(/^@[^\\/]+\\//, '')] ? pkgHint.replace(/^@[^\\/]+\\//, '') : null);\n  if (!key && pkgHint === '@popperjs/core') key = 'popper';\n  if (!key || !LIB[key]) continue;\n  if (!version) { const v = r.u.searchParams.get('ver') || r.u.searchParams.get('v') || r.u.searchParams.get('version');\n    if (v && /^\\d+\\.\\d+(\\.\\d+)?$/.test(v) && (key.startsWith('jquery') || /\\/wp-includes\\/js\\//.test(path))) { version = v; src = 'query'; } }\n  let [name, pkg] = LIB[key];\n  if (pkgHint === '@popperjs/core' || (key === 'popper' && version && +version.split('.')[0] >= 2)) pkg = '@popperjs/core';\n  if (key === 'angular' && version && +version.split('.')[0] >= 2) continue;     // Angular 2+ is not the \"angular\" package\n  putLib({ name, pkg, version, src: version ? src : 'file', host: r.host, third: third(r.host), url: r.u.href.slice(0, 300) });\n}\n// banners of the own JS files (first 4 KB)\nconst BANNER = [\n  [/jQuery UI - v(\\d+\\.\\d+\\.\\d+)/, 'jQuery UI', 'jquery-ui'], [/jQuery Migrate[ -]+v?(\\d+\\.\\d+\\.\\d+)/, 'jQuery Migrate', 'jquery-migrate'],\n  [/jQuery (?:JavaScript Library )?v(\\d+\\.\\d+\\.\\d+)/, 'jQuery', 'jquery'], [/Bootstrap v(\\d+\\.\\d+\\.\\d+)/, 'Bootstrap', 'bootstrap'],\n  [/@popperjs\\/core v(\\d+\\.\\d+\\.\\d+)/, 'Popper', '@popperjs/core'], [/Popper\\.js v?(\\d+\\.\\d+\\.\\d+)/i, 'Popper', 'popper.js'],\n  [/Lodash[\\s\\S]{0,400}?VERSION\\s*=\\s*[\"'](\\d+\\.\\d+\\.\\d+)/, 'Lodash', 'lodash'], [/Underscore\\.js (\\d+\\.\\d+\\.\\d+)/, 'Underscore', 'underscore'],\n  [/moment\\.js\\s*\\n\\/\\/! version : (\\d+\\.\\d+\\.\\d+)/, 'Moment.js', 'moment'], [/AngularJS v(\\d+\\.\\d+\\.\\d+)/, 'AngularJS', 'angular'],\n  [/Vue\\.js v(\\d+\\.\\d+\\.\\d+)/i, 'Vue', 'vue'], [/handlebars v(\\d+\\.\\d+\\.\\d+)/i, 'Handlebars', 'handlebars'], [/Swiper (\\d+\\.\\d+\\.\\d+)/, 'Swiper', 'swiper'],\n  [/GSAP (\\d+\\.\\d+\\.\\d+)/, 'GSAP', 'gsap'], [/Chart\\.js v(\\d+\\.\\d+\\.\\d+)/, 'Chart.js', 'chart.js'], [/DOMPurify (\\d+\\.\\d+\\.\\d+)/, 'DOMPurify', 'dompurify'],\n  [/Select2 (\\d+\\.\\d+\\.\\d+)/, 'Select2', 'select2'], [/lazysizes - v(\\d+\\.\\d+\\.\\d+)/, 'lazysizes', 'lazysizes'], [/Isotope PACKAGED v(\\d+\\.\\d+\\.\\d+)/, 'Isotope', 'isotope-layout'],\n  [/Masonry PACKAGED v(\\d+\\.\\d+\\.\\d+)/, 'Masonry', 'masonry-layout'], [/fancyBox v(\\d+\\.\\d+\\.\\d+)/i, 'fancyBox', '@fancyapps/fancybox'],\n  [/Lightbox v(\\d+\\.\\d+\\.\\d+)/, 'Lightbox2', 'lightbox2'], [/axios v(\\d+\\.\\d+\\.\\d+)/, 'axios', 'axios'], [/[Mm]odernizr (\\d+\\.\\d+\\.\\d+)/, 'Modernizr', 'modernizr'],\n  [/Slick[\\s\\S]{0,300}?Version: (\\d+\\.\\d+\\.\\d+)/, 'Slick', 'slick-carousel'], [/Video\\.js (\\d+\\.\\d+\\.\\d+)/, 'Video.js', 'video.js'],\n  [/jQuery Validation Plugin v(\\d+\\.\\d+\\.\\d+)/, 'jQuery Validation', 'jquery-validation'], [/JavaScript Cookie v(\\d+\\.\\d+\\.\\d+)/, 'js-cookie', 'js-cookie'],\n  [/Leaflet (\\d+\\.\\d+\\.\\d+)/, 'Leaflet', 'leaflet'], [/sweetalert2 v(\\d+\\.\\d+\\.\\d+)/i, 'SweetAlert2', 'sweetalert2'],\n];\nfor (const j of Array.isArray(pb.js) ? pb.js : []) {\n  const head = String(j.head || ''), h = (String(j.url || '').match(/^https?:\\/\\/([^\\/:?#]+)/i) || [])[1] || fhost;\n  for (const [re, name, pkg] of BANNER) {\n    const m = head.match(re);\n    if (m) putLib({ name, pkg, version: m[1], src: 'banner', host: h.toLowerCase(), third: false, url: String(j.url).slice(0, 300) });\n  }\n}\n\n// ---- technologies with version: headers and meta generator\nconst hd = pb.headers || {};\nconst tech = [];\nconst putTech = (name, version, src) => { if (name && !tech.find(t => t.name === name) && tech.length < 12) tech.push({ name, version: version || '', src }); };\nconst sv = String(hd.server || '');\nfor (const m of sv.matchAll(/([A-Za-z][A-Za-z0-9 ._-]*?)\\/(\\d+(?:\\.\\d+){0,3})/g)) putTech(m[1].trim(), m[2], 'Server');\nif (sv && !/\\//.test(sv)) putTech(sv.split(/[\\s(]/)[0].slice(0, 30), '', 'Server');\nfor (const m of String(hd['x-powered-by'] || '').matchAll(/([A-Za-z][A-Za-z0-9 .#_-]*?)(?:\\/(\\d+(?:\\.\\d+){0,3}))?(?:,|$)/g)) if (m[1].trim()) putTech(m[1].trim().slice(0, 30), m[2] || '', 'X-Powered-By');\nif (hd['x-aspnet-version']) putTech('ASP.NET', String(hd['x-aspnet-version']).slice(0, 20), 'X-AspNet-Version');\nconst genM = body.match(/<meta[^>]+name=[\"']generator[\"'][^>]*content=[\"']([^\"']+)/i) || body.match(/<meta[^>]+content=[\"']([^\"']+)[\"'][^>]*name=[\"']generator[\"']/i);\nconst gen = genM ? dec(genM[1]).slice(0, 80) : String(hd['x-generator'] || '');\nif (gen) { const m = gen.match(/^([A-Za-z][A-Za-z0-9 !._-]*?)\\s*v?(\\d+(?:\\.\\d+){0,3})?(?:\\s|$|\\()/); if (m) putTech(m[1].replace(/!.*$/, '').trim().slice(0, 30), m[2] || '', 'Generator'); }\nconst has = (re) => re.test(body);\nif (has(/\\/wp-(content|includes)\\//)) putTech('WordPress', '', 'HTML');\nif (has(/cdn\\.shopify\\.com/) || hd['x-shopify-stage']) putTech('Shopify', '', 'HTML');\nif (hd['x-drupal-cache'] || hd['x-drupal-dynamic-cache'] || has(/drupal-settings-json/)) putTech('Drupal', '', 'HTML');\nif (has(/\\/_next\\/static\\//)) putTech('Next.js', '', 'HTML');\nif (has(/\\/_nuxt\\/|window\\.__NUXT__/)) putTech('Nuxt', '', 'HTML');\nif (hd['cf-ray']) putTech('Cloudflare', '', 'Header');\n\n// ---- cookies (names and flags only; set by the server with the page)\nconst CK = [[/^_ga|^_gid$|^_gat/, 'Google Analytics'], [/^_fbp$|^_fbc$/, 'Meta Pixel'], [/^PHPSESSID$/, 'PHP-Session'], [/^wordpress_|^wp-settings|^wp_/, 'WordPress'],\n  [/^__cf_bm$|^cf_clearance$|^__cflb$/, 'Cloudflare'], [/^_hj/, 'Hotjar'], [/^JSESSIONID$/, 'Java-Session'], [/^ASP\\.NET_SessionId$|^\\.AspNetCore/, 'ASP.NET-Session'],\n  [/^laravel_session$|^XSRF-TOKEN$/, 'Laravel'], [/^_shopify_|^cart|^secure_customer_sig$/, 'Shopify'], [/^CookieConsent$/, 'Cookiebot'], [/^borlabs-cookie$/, 'Borlabs Cookie'],\n  [/^_pk_|^MATOMO_/, 'Matomo'], [/^AWSALB/, 'AWS Load Balancer'], [/^__stripe_/, 'Stripe'], [/^ci_session$/, 'CodeIgniter'], [/^frontend$|^mage-/, 'Magento'],\n  [/^sw-/, 'Shopware'], [/^fe_typo_user$|^be_typo_user$/, 'TYPO3'], [/^cookielawinfo|^viewed_cookie_policy$/, 'CookieLawInfo'], [/^OptanonConsent$|^OptanonAlertBoxClosed$/, 'OneTrust'],\n  [/^_clck$|^_clsk$/, 'Microsoft Clarity'], [/^__hs|^hubspotutk$/, 'HubSpot'], [/^_gcl_/, 'Google Ads'], [/^ARRAffinity/, 'Azure'], [/^SERVERID$|^route$/, 'Load Balancer']];\nconst cookies = (Array.isArray(pb.cookies) ? pb.cookies : []).slice(0, 30).map(c => {\n  const hint = (CK.find(([re]) => re.test(String(c.name))) || [])[1] || '';\n  return { name: String(c.name).slice(0, 60), secure: !!c.secure, httponly: !!c.httponly, samesite: String(c.samesite || '').slice(0, 10),\n           days: c.days == null ? null : Math.round(Number(c.days)), hint };\n});\n\n// ---- hosts and resources\nconst TYPES = ['script', 'css', 'font', 'iframe', 'img', 'media'];\nconst hosts = {};\nfor (const r of res) {\n  const h = hosts[r.host] || (hosts[r.host] = { host: r.host, third: third(r.host), n: 0, types: {}, svc: '', cat: '' });\n  const s = svcOf(r.host); if (s) { h.svc = s.name; h.cat = s.cat; }\n  if (r.t !== 'hint') h.n++;\n  h.types[r.t] = (h.types[r.t] || 0) + 1;\n}\nconst counts = {};\nfor (const t of TYPES) counts[t] = { own: 0, third: 0 };\nfor (const r of res) if (counts[r.t]) counts[r.t][third(r.host) ? 'third' : 'own']++;\nconst short = u => { const p = u.pathname + (u.search.length > 1 && u.search.length < 30 ? u.search : ''); return p.length > 90 ? p.slice(0, 89) + '…' : p; };\nconst resources = res.filter(r => r.t !== 'hint').map(r => ({ t: r.t, host: r.host, path: short(r.u), third: third(r.host), via: r.via }));\n\n// ---- OSV query: npm libraries with a full version\nconst qmap = [];\nconst queries = [];\nlibs.forEach((l, i) => {\n  if (!l.version || !l.pkg || queries.length >= 20) return;\n  const v = l.version.split('.').length === 2 ? l.version + '.0' : l.version;\n  queries.push({ package: { name: l.pkg, ecosystem: 'npm' }, version: v }); qmap.push(i);\n});\nconst jsKb = (Array.isArray(pb.js) ? pb.js : []).reduce((a, j) => a + (Number(j.kb) || 0), 0);\nreturn [{ json: {\n  seq: { ok, status: String(pb.status || 'error'), code: Number(pb.code || 0), final_url: finalUrl.slice(0, 300), host: fhost, https: /^https:/i.test(finalUrl),\n         html_kb: Math.round(Number(pb.html_bytes || 0) / 1024), html_cut: !!pb.html_cut, js_kb: jsKb, js_read: (pb.js || []).length,\n         ttfb_ms: Number(pb.ttfb_ms || 0), resources: resources.slice(0, 400), hosts: Object.values(hosts), counts, libs, qmap, tech, cookies },\n  osv: { queries } } }];"
   },
   "id": "c0",
   "name": "Genom sequenzieren",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    480,
    300
   ]
  },
  {
   "parameters": {
    "url": "https://api.osv.dev/v1/querybatch",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website DNA; +https://daniele-michelin.com/website-dna/)"
      }
     ]
    },
    "options": {
     "timeout": 15000
    },
    "method": "POST",
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json.osv) }}"
   },
   "id": "h1",
   "name": "OSV-Labor: Schwachstellen",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    720,
    160
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// One item per known vulnerability (max 16 details); without findings a single empty item, so the flow goes on.\nconst r = $input.first().json || {};\nconst results = Array.isArray(r.results) ? r.results : [];\nconst out = [], seen = new Set();\nresults.forEach((x, q) => { for (const v of (x && Array.isArray(x.vulns) ? x.vulns : [])) {\n  if (out.length < 16 && v && v.id && !seen.has(v.id)) { seen.add(v.id); out.push({ json: { id: String(v.id), q } }); } } });\nreturn out.length ? out : [{ json: { id: '', q: -1 } }];"
   },
   "id": "c1",
   "name": "OSV-Akten auswählen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    940,
    160
   ]
  },
  {
   "parameters": {
    "url": "={{ 'https://api.osv.dev/v1/vulns/' + encodeURIComponent($json.id || 'NONE') }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website DNA; +https://daniele-michelin.com/website-dna/)"
      }
     ]
    },
    "options": {
     "timeout": 10000
    }
   },
   "id": "h2",
   "name": "OSV-Akten laden",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1160,
    160
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "url": "={{ 'https://YOUR-SITE.example/tools/api/city.php?d=' + encodeURIComponent($('Besucher-Knopf (Webhook)').first().json.body.domain) }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "User-Agent",
       "value": "Mozilla/5.0 (compatible; daniele-michelin.com website DNA; +https://daniele-michelin.com/website-dna/)"
      },
      {
       "name": "X-City-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "options": {
     "timeout": 60000
    }
   },
   "id": "h3",
   "name": "Prüfstelle: Header und TLS",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    720,
    460
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "numberInputs": 2
   },
   "id": "m1",
   "name": "Befunde zusammenführen",
   "type": "n8n-nodes-base.merge",
   "typeVersion": 3,
   "position": [
    1400,
    300
   ]
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Puts the findings together: CVEs per library (OSV), security headers and TLS (Prüfstelle), the base pairs of the helix,\n// the composition own/third party, then the prompt for the AI geneticist.\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 S = pick('Genom sequenzieren').seq;\nconst pr = pick('Prüfstelle: Header und TLS') || {};\nconst okPr = pr.status === 'ok';\n\n// ---- vulnerabilities\nconst SEV = { LOW: 1, MODERATE: 2, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };\nconst det = {};\ntry { for (const it of $('OSV-Akten laden').all()) { const j = it.json || {}; if (j && typeof j.id === 'string') det[j.id] = j; } } catch (e) {}\nconst qb = pick('OSV-Labor: Schwachstellen') || {};\nconst results = Array.isArray(qb.results) ? qb.results : [];\nconst libs = S.libs.map(l => ({ ...l, vulns: [], vuln_total: 0 }));\nresults.forEach((x, q) => {\n  const l = libs[S.qmap[q]]; if (!l) return;\n  const vs = x && Array.isArray(x.vulns) ? x.vulns : [];\n  l.vuln_total = vs.length;\n  for (const v of vs) {\n    const d = det[v.id];\n    let sev = d ? String((d.database_specific || {}).severity || '').toUpperCase() : '';\n    if (sev === 'MEDIUM') sev = 'MODERATE';\n    const cve = d && Array.isArray(d.aliases) ? (d.aliases.find(a => /^CVE-\\d{4}-\\d+$/.test(a)) || '') : (/^CVE-/.test(v.id) ? v.id : '');\n    const summary = d ? String(d.summary || String(d.details || '').split('\\n')[0]).replace(/\\s+/g, ' ').slice(0, 160) : '';\n    l.vulns.push({ id: String(v.id).slice(0, 40), cve, sev: SEV[sev] ? sev : '', summary });\n  }\n  l.vulns.sort((a, b) => (SEV[b.sev] || 0) - (SEV[a.sev] || 0));\n  l.vulns = l.vulns.slice(0, 12);\n});\nconst maxSev = l => l.vulns.reduce((m, v) => (SEV[v.sev] || 0) > (SEV[m] || 0) ? v.sev : m, '');\n\n// ---- security (Prüfstelle)\nconst H = Object.assign({ hsts: false, csp: false, xcto: false, xfo: false, referrer: false, permissions: false }, okPr && pr.security ? pr.security.headers : {});\nconst security = { score: okPr ? pr.security.score : 0, headers: H, checked: okPr };\nconst tls = { score: okPr ? pr.tls.score : 0, days_left: okPr ? pr.tls.days_left : null, proto: okPr ? pr.tls.proto : '' };\nconst https = S.https || (okPr && !!pr.https);\n\n// ---- composition\nconst C = S.counts;\nconst own = Object.values(C).reduce((a, c) => a + c.own, 0), thirdN = Object.values(C).reduce((a, c) => a + c.third, 0);\nconst third_pct = own + thirdN ? Math.round(100 * thirdN / (own + thirdN)) : 0;\nconst extHosts = S.hosts.filter(h => h.third && h.n > 0);\nconst services = [];\nfor (const h of S.hosts.slice().sort((a, b) => b.n - a.n)) if (h.third && h.svc && !services.find(s => s.name === h.svc)) services.push({ name: h.svc, cat: h.cat, host: h.host });\n\n// ---- base pairs of the helix (max 64): own = blue, third = violet, sec = green, secx = missing protection, cve = red\nconst P = [];\nconst pair = (k, n, o) => P.push(Object.assign({ k, n: String(n).slice(0, 40), v: '', h: '', ty: '' }, o || {}));\nconst L = { de: { img: 'Bilder', font: 'Schriften', media: 'Medien', doc: 'Startseite' }, en: { img: 'images', font: 'fonts', media: 'media', doc: 'homepage' },\n            it: { img: 'immagini', font: 'font', media: 'media', doc: 'homepage' } }[lang];\npair('own', 'HTML ' + L.doc, { h: S.host, ty: 'doc', v: S.html_kb + ' KB' });\nconst fileName = p => (String(p).split('?')[0].split('/').filter(Boolean).pop() || String(p)).slice(0, 40);\nconst libFiles = new Set(libs.map(l => fileName(String(l.url).replace(/^https?:\\/\\/[^\\/]+/, ''))));\nlibs.forEach((l, i) => pair(l.vulns.length ? 'cve' : (l.third ? 'third' : 'own'), l.name, { v: l.version || '', h: l.host, ty: 'lib', li: i, sev: maxSev(l) }));\nfor (const s of services) pair('third', s.name, { h: s.host, ty: s.cat });\nfor (const h of extHosts) if (!h.svc) pair('third', h.host, { h: h.host, ty: Object.keys(h.types).filter(t => t !== 'hint').sort((a, b) => h.types[b] - h.types[a])[0] || 'script' });\nfor (const t of S.tech) if (t.version) pair('own', t.name, { v: t.version, h: S.host, ty: 'tech' });\nconst ownRes = S.resources.filter(r => !r.third);\nfor (const t of ['script', 'css', 'iframe']) ownRes.filter(r => r.t === t && !libFiles.has(fileName(r.path))).slice(0, t === 'script' ? 10 : 6)\n  .forEach(r => pair('own', fileName(r.path), { h: r.host, ty: t }));\nfor (const c of S.cookies.slice(0, 6)) pair('own', 'Cookie ' + c.name, { h: S.host, ty: 'cookie', v: c.hint });\nfor (const t of ['font', 'img', 'media']) { const n = ownRes.filter(r => r.t === t).length; if (n) pair('own', L[t] + ' ×' + n, { h: S.host, ty: t }); }\nconst SECN = { hsts: 'HSTS', csp: 'Content-Security-Policy', xcto: 'X-Content-Type-Options', xfo: 'X-Frame-Options', referrer: 'Referrer-Policy', permissions: 'Permissions-Policy' };\nconst secP = Object.keys(SECN).map(k => ({ k: H[k] ? 'sec' : 'secx', n: SECN[k], v: '', h: S.host, ty: 'header' }));\nsecP.push({ k: https ? 'sec' : 'secx', n: 'HTTPS', v: '', h: S.host, ty: 'tls' });\nsecP.push({ k: tls.proto && (tls.days_left == null || tls.days_left > 14) ? 'sec' : 'secx', n: 'TLS' + (tls.proto ? ' ' + tls.proto.replace(/^TLSv/, '') : ''),\n  v: tls.days_left != null ? String(tls.days_left) + 'd' : '', h: S.host, ty: 'tls' });\n// keep the important ones when there are too many, then spread the security pairs evenly through the strand\nconst prio = p => p.k === 'cve' ? 0 : p.ty === 'doc' ? 1 : p.ty === 'lib' ? 2 : p.k === 'third' ? 3 : p.ty === 'tech' ? 4 : 5;\nconst core = P.map((p, i) => ({ p, i })).sort((a, b) => prio(a.p) - prio(b.p) || a.i - b.i).slice(0, 64 - secP.length).sort((a, b) => a.i - b.i).map(x => x.p);\nconst pairs = [], total = core.length + secP.length, secAt = new Set(secP.map((_, j) => Math.min(total - 1, Math.round((j + 0.5) * total / secP.length))));\nfor (let i = 0, si = 0; i < total; i++) pairs.push((secAt.has(i) && si < secP.length) || !core.length ? secP[si++] : core.shift());\n\n// ---- the record for the page\nconst vulnLibs = libs.filter(l => l.vulns.length);\nconst vulns_total = libs.reduce((a, l) => a + l.vuln_total, 0);\nconst dna = { id: req.id, lang, domain, host: S.host, https, reachable: S.ok, code: S.code, html_kb: S.html_kb, js_kb: S.js_kb, js_read: S.js_read, ttfb_ms: S.ttfb_ms,\n  counts: C, own, third: thirdN, third_pct, hosts_ext: extHosts.length,\n  hosts: S.hosts.sort((a, b) => (b.third - a.third) || b.n - a.n).slice(0, 40), services: services.slice(0, 30),\n  libs: libs.map(l => ({ name: l.name, pkg: l.pkg, version: l.version || '', src: l.src, host: l.host, third: l.third, vulns: l.vulns, vuln_total: l.vuln_total })),\n  tech: S.tech, cookies: S.cookies, res: S.resources.slice(0, 80), security, tls, pairs, vulns_total };\n\n// ---- prompt\nconst missing = Object.keys(SECN).filter(k => !H[k]).map(k => SECN[k]);\nconst facts = { domain, reachable: S.ok, base_pairs: pairs.length, resources_own: own, resources_third_party: thirdN, third_party_percent: third_pct,\n  external_hosts: extHosts.length, external_services: services.map(s => s.name + ' (' + s.cat + ')'),\n  libraries: libs.map(l => ({ name: l.name, version: l.version || 'unknown', third_party_host: l.third ? l.host : false, known_vulnerabilities: l.vuln_total,\n    worst_severity: maxSev(l) || 'none', cves: l.vulns.map(v => v.cve || v.id).slice(0, 4) })),\n  versions_disclosed_in_headers: S.tech.filter(t => t.version && /Server|Powered|AspNet/.test(t.src)).map(t => t.name + ' ' + t.version),\n  cms_or_generator: S.tech.filter(t => t.src === 'Generator' || t.src === 'HTML').map(t => (t.name + ' ' + t.version).trim()),\n  cookies_set_by_server: S.cookies.map(c => ({ name: c.name, secure: c.secure, httponly: c.httponly, samesite: c.samesite || 'not set', days: c.days })),\n  security_headers_missing: missing, https, tls_version: tls.proto || 'unknown', certificate_days_left: tls.days_left, homepage_html_kb: S.html_kb };\nconst LANG = { de: 'German, informal \"du\"', en: 'English', it: 'Italian, informal \"tu\"' }[lang];\nconst prompt = 'You are the geneticist of \"Website DNA\": you read the genome of a website (all components its homepage loads) and give a diagnosis. '\n  + `Write in ${LANG}. Lab results (use ONLY these facts and numbers, never invent a library, version, CVE, cookie or number): ${JSON.stringify(facts)}. `\n  + 'Answer with JSON only: {\"diagnosis\":\"\",\"therapies\":[\"\",\"\",\"\"]}. '\n  + 'diagnosis: 3-4 sentences, max 520 characters, like a friendly geneticist with a light lab metaphor (genes, mutations, foreign DNA), '\n  + 'focusing on what matters for privacy (GDPR: third parties that receive the visitor\\'s IP address on page load, e.g. fonts, analytics, video) and security '\n  + '(libraries with known vulnerabilities, version numbers disclosed in headers, missing security headers). Quote 2-4 numbers exactly. '\n  + 'Never recommend adding a security header, HTTPS or anything else the facts show as already present. ' \n  + 'If everything is clean, say so plainly. therapies: exactly 3 concrete, actionable fixes for THIS site, ordered by impact, max 150 characters each, '\n  + 'naming the library, service or header (e.g. update a library to a fixed version, host fonts locally, load videos only after consent, add a missing header). '\n  + 'No legal advice beyond plain facts, no alarmism, no hashtags, no emojis, no markdown.';\nreturn [{ json: { dna,\n  body: { model: 'openai/gpt-oss-120b', temperature: 0.6, max_tokens: 3000, reasoning_effort: 'low', response_format: { type: 'json_object' },\n          messages: [{ role: 'user', content: prompt }] } } }];"
   },
   "id": "c2",
   "name": "Befund erstellen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1620,
    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": 90000
    }
   },
   "id": "a1",
   "name": "Genetiker (KI)",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1840,
    300
   ],
   "retryOnFail": true,
   "maxTries": 2,
   "waitBetweenTries": 2000,
   "onError": "continueRegularOutput",
   "credentials": {
    "httpHeaderAuth": {
     "id": "",
     "name": "YOUR OpenAI-compatible API key (Header Auth: Authorization = Bearer …)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Reads the AI diagnosis; if it fails, is too short or too long, diagnosis and therapies come from a template built from the same facts.\nconst d = $('Befund erstellen').first().json.dna;\nlet g = '', th = [];\ntry { const m = String($input.first().json.choices[0].message.content).match(/\\{[\\s\\S]*\\}/); const j = JSON.parse(m[0]);\n  g = String(j.diagnosis || ''); th = Array.isArray(j.therapies) ? j.therapies.map(String) : []; } catch (e) {}\nconst clean = s => s.replace(/[*_#`]/g, '').replace(/\\s+/g, ' ').trim();\ng = clean(g);\nif (g.length > 560) { const cut = g.slice(0, 560); const p = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? ')); g = p > 200 ? cut.slice(0, p + 1) : ''; }\nth = th.map(clean).filter(t => t.length >= 12).map(t => t.length > 170 ? t.slice(0, 168).replace(/\\s+\\S*$/, '') + ' …' : t).slice(0, 3);\n// drop AI therapies that contradict the measurement (e.g. \"add HSTS\" when HSTS is already set)\nconst HDR_RE = { hsts: /hsts|strict-transport/i, csp: /content-security|\\bcsp\\b/i, xcto: /nosniff|x-content-type/i, xfo: /x-frame|frame-ancestors|clickjack/i,\n                 referrer: /referrer-policy/i, permissions: /permissions-policy|feature-policy/i };\nconst hdrOk = (d.security && d.security.headers) || {};\nth = th.filter(t => !Object.keys(HDR_RE).some(k => hdrOk[k] === true && HDR_RE[k].test(t) && /hinzuf|einf|aktivier|setz|add|enable|introduc|set up|aggiung|attiv|introdu|impost/i.test(t)));\nconst lang = d.lang;\nconst vl = d.libs.filter(l => l.vulns.length);\nconst svc = d.services.map(s => s.name);\nconst fonts = d.services.find(s => s.cat === 'fonts'), video = d.services.find(s => s.cat === 'video'), ana = d.services.find(s => s.cat === 'analytics' || s.cat === 'ads');\nconst disc = d.tech.find(t => t.version && /Server|Powered|AspNet/.test(t.src));\nconst miss = d.security.checked ? Object.keys(d.security.headers).filter(k => !d.security.headers[k]) : [];\nconst HN = { hsts: 'HSTS', csp: 'Content-Security-Policy', xcto: 'X-Content-Type-Options', xfo: 'X-Frame-Options', referrer: 'Referrer-Policy', permissions: 'Permissions-Policy' };\nconst T = {\n  de: {\n    unr: `Von ${d.domain} ließ sich keine Probe entnehmen: Die Startseite hat nicht (oder nur mit Fehler ${d.code || '–'}) geantwortet. Das Genom bleibt deshalb fast leer.`,\n    diag: `Das Genom von ${d.domain} hat ${d.pairs.length} Basenpaare: ${d.own} eigene Bausteine und ${d.third} fremde (${d.third_pct} %) von ${d.hosts_ext} externen Hosts. `\n      + (vl.length ? `${vl.length} Bibliothek${vl.length > 1 ? 'en tragen' : ' trägt'} bekannte Mutationen, insgesamt ${d.vulns_total} Schwachstellen. ` : 'Bekannte Mutationen in den erkannten Bibliotheken: keine. ')\n      + (miss.length ? `${miss.length} von 6 Schutz-Headern fehlen.` : 'Alle 6 Schutz-Header sind vorhanden.'),\n    upd: l => `${l.name} ${l.version} aktualisieren: ${l.vuln_total} bekannte Schwachstellen (${l.vulns[0].cve || l.vulns[0].id}).`,\n    font: s => `${s.name} lokal hosten: Beim Laden geht sonst die IP-Adresse jedes Besuchers an ${s.host}.`,\n    video: s => `${s.name}-Videos erst nach Einwilligung laden (Zwei-Klick-Lösung oder Vorschaubild).`,\n    ana: s => `${s.name} erst nach Einwilligung starten und im Cookie-Banner aufführen.`,\n    disc: t => `Versionsangabe „${t.name}/${t.version}“ im ${t.src}-Header ausblenden.`,\n    hdr: k => `Fehlenden Header ${HN[k]} setzen.`, sri: 'Externe Skripte mit Subresource Integrity (integrity-Attribut) absichern.',\n    keep: 'Den sauberen Zustand halten: Bibliotheken regelmäßig aktualisieren und neue Drittanbieter bewusst prüfen.',\n    mon: 'Abhängigkeiten automatisch überwachen lassen (z. B. OSV-Scanner oder Dependabot), damit neue CVEs sofort auffallen.',\n    doc: 'Nach jedem größeren Update das Genom neu sequenzieren und neue Drittanbieter in der Datenschutzerklärung nennen.' },\n  en: {\n    unr: `No sample could be taken from ${d.domain}: the homepage did not answer (or only with error ${d.code || '–'}). So the genome stays almost empty.`,\n    diag: `The genome of ${d.domain} has ${d.pairs.length} base pairs: ${d.own} own building blocks and ${d.third} foreign ones (${d.third_pct} %) from ${d.hosts_ext} external hosts. `\n      + (vl.length ? `${vl.length} librar${vl.length > 1 ? 'ies carry' : 'y carries'} known mutations, ${d.vulns_total} vulnerabilities in total. ` : 'Known mutations in the detected libraries: none. ')\n      + (miss.length ? `${miss.length} of 6 protective headers are missing.` : 'All 6 protective headers are present.'),\n    upd: l => `Update ${l.name} ${l.version}: ${l.vuln_total} known vulnerabilities (${l.vulns[0].cve || l.vulns[0].id}).`,\n    font: s => `Host ${s.name} locally: otherwise every visitor's IP address goes to ${s.host} on page load.`,\n    video: s => `Load ${s.name} videos only after consent (two-click solution or preview image).`,\n    ana: s => `Start ${s.name} only after consent and list it in the cookie banner.`,\n    disc: t => `Hide the version \"${t.name}/${t.version}\" in the ${t.src} header.`,\n    hdr: k => `Add the missing ${HN[k]} header.`, sri: 'Protect external scripts with Subresource Integrity (integrity attribute).',\n    keep: 'Keep it clean: update libraries regularly and review every new third party on purpose.',\n    mon: 'Monitor dependencies automatically (e.g. OSV-Scanner or Dependabot) so new CVEs show up right away.',\n    doc: 'Sequence the genome again after every bigger update and list new third parties in the privacy policy.' },\n  it: {\n    unr: `Da ${d.domain} non è stato possibile prelevare un campione: la homepage non ha risposto (o solo con errore ${d.code || '–'}). Il genoma resta quasi vuoto.`,\n    diag: `Il genoma di ${d.domain} ha ${d.pairs.length} coppie di basi: ${d.own} componenti propri e ${d.third} estranei (${d.third_pct} %) da ${d.hosts_ext} host esterni. `\n      + (vl.length ? `${vl.length} librer${vl.length > 1 ? 'ie portano' : 'ia porta'} mutazioni note, ${d.vulns_total} vulnerabilità in tutto. ` : 'Mutazioni note nelle librerie riconosciute: nessuna. ')\n      + (miss.length ? `Mancano ${miss.length} header di protezione su 6.` : 'Tutti i 6 header di protezione ci sono.'),\n    upd: l => `Aggiorna ${l.name} ${l.version}: ${l.vuln_total} vulnerabilità note (${l.vulns[0].cve || l.vulns[0].id}).`,\n    font: s => `Ospita ${s.name} in locale: altrimenti l'indirizzo IP di ogni visitatore va a ${s.host} al caricamento.`,\n    video: s => `Carica i video di ${s.name} solo dopo il consenso (soluzione a due clic o anteprima).`,\n    ana: s => `Avvia ${s.name} solo dopo il consenso e indicalo nel banner dei cookie.`,\n    disc: t => `Nascondi la versione «${t.name}/${t.version}» nell'header ${t.src}.`,\n    hdr: k => `Imposta l'header mancante ${HN[k]}.`, sri: 'Proteggi gli script esterni con Subresource Integrity (attributo integrity).',\n    keep: 'Mantieni il sito pulito: aggiorna spesso le librerie e valuta ogni nuova terza parte.',\n    mon: 'Monitora le dipendenze in automatico (per esempio OSV-Scanner o Dependabot), così le nuove CVE saltano subito all\\'occhio.',\n    doc: 'Rifai il sequenziamento dopo ogni aggiornamento importante e indica le nuove terze parti nell\\'informativa privacy.' } }[lang];\nif (!d.reachable) g = T.unr;\nelse if (g.length < 80) g = T.diag;\nif (th.length < 3) {\n  const auto = [];\n  vl.forEach(l => auto.push(T.upd(l)));\n  if (fonts) auto.push(T.font(fonts));\n  if (ana) auto.push(T.ana(ana));\n  if (video) auto.push(T.video(video));\n  if (disc) auto.push(T.disc(disc));\n  miss.forEach(k => auto.push(T.hdr(k)));\n  if (d.services.some(s => s.cat === 'cdn')) auto.push(T.sri);\n  auto.push(T.keep, T.mon, T.doc);\n  for (const a of auto) if (th.length < 3 && !th.includes(a)) th.push(a);\n}\nreturn [{ json: { ...d, diagnosis: g.slice(0, 600), therapies: th.slice(0, 3) } }];"
   },
   "id": "c3",
   "name": "Diagnose prüfen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    2060,
    300
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://YOUR-SITE.example/dna-ingest.php",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "X-Dna-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json) }}",
    "options": {
     "timeout": 60000
    }
   },
   "id": "p1",
   "name": "Genom an die Seite",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    2280,
    300
   ]
  }
 ],
 "connections": {
  "Besucher-Knopf (Webhook)": {
   "main": [
    [
     {
      "node": "Probe entnehmen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Probe entnehmen": {
   "main": [
    [
     {
      "node": "Genom sequenzieren",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Genom sequenzieren": {
   "main": [
    [
     {
      "node": "OSV-Labor: Schwachstellen",
      "type": "main",
      "index": 0
     },
     {
      "node": "Prüfstelle: Header und TLS",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "OSV-Labor: Schwachstellen": {
   "main": [
    [
     {
      "node": "OSV-Akten auswählen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "OSV-Akten auswählen": {
   "main": [
    [
     {
      "node": "OSV-Akten laden",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "OSV-Akten laden": {
   "main": [
    [
     {
      "node": "Befunde zusammenführen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Prüfstelle: Header und TLS": {
   "main": [
    [
     {
      "node": "Befunde zusammenführen",
      "type": "main",
      "index": 1
     }
    ]
   ]
  },
  "Befunde zusammenführen": {
   "main": [
    [
     {
      "node": "Befund erstellen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Befund erstellen": {
   "main": [
    [
     {
      "node": "Genetiker (KI)",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Genetiker (KI)": {
   "main": [
    [
     {
      "node": "Diagnose prüfen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Diagnose prüfen": {
   "main": [
    [
     {
      "node": "Genom an die Seite",
      "type": "main",
      "index": 0
     }
    ]
   ]
  }
 },
 "active": false,
 "settings": {
  "executionOrder": "v1",
  "timezone": "Europe/Berlin",
  "saveDataSuccessExecution": "none",
  "saveDataErrorExecution": "all"
 },
 "pinData": {}
}