{
 "name": "Link galaxy (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": "7d2f4a10-3c55-4f0e-9b1a-6a0000000008"
  },
  {
   "parameters": {
    "url": "={{ 'https://YOUR-SITE.example/tools/api/galaxy-crawl.php?d=' + encodeURIComponent($json.body.domain) }}",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "X-Galaxy-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "options": {
     "timeout": 75000
    }
   },
   "id": "h1",
   "name": "Sternwarte: Website abtasten",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    240,
    300
   ],
   "onError": "continueRegularOutput"
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Turns the raw crawl into a galaxy: click depth from the homepage (BFS over links and redirects), links in/out per page,\n// sections (first path segment, a language prefix like /en/ is skipped), broken links (4xx/5xx), redirects, orphans (sitemap only),\n// deep pages (> 3 clicks), top pages by inbound links and a link health score 0-100. Then writes the prompt for the AI astronomer.\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 c = pick('Sternwarte: Website abtasten') || {};\nconst status = c.ok && ['ok', 'nx', 'unreachable', 'robots', 'busy'].includes(c.status) ? c.status : 'unreachable';\nconst P = s => String(s == null ? '' : s).replace(/[^\\x21-\\x7e]/g, '').slice(0, 200);\nconst raw = status === 'ok' && Array.isArray(c.nodes) ? c.nodes.slice(0, 150) : [];\nconst nodes = raw.map(n => ({ p: P(n.p) || '/', s: Number.isInteger(n.s) ? n.s : null, r: Number.isInteger(n.r) ? n.r : null,\n  x: Math.max(0, Number(n.x) || 0), ni: n.ni ? 1 : 0, rb: n.rb ? 1 : 0, t: String(n.t || '').replace(/\\s+/g, ' ').slice(0, 90), o: 0 }));\nconst N0 = nodes.length;\nconst truncated = !!c.truncated;\nconst orphans_n = status === 'ok' ? Math.max(0, Number(c.orphans_n) || 0) : 0;\n(status === 'ok' && Array.isArray(c.orphans) ? c.orphans : []).map(P).filter(p => p.startsWith('/')).slice(0, 60)\n  .forEach(p => nodes.push({ p, s: null, r: null, x: 0, ni: 0, rb: 0, t: '', o: 1 }));\nconst edges = (Array.isArray(c.edges) ? c.edges : []).filter(e => Array.isArray(e) && Number.isInteger(e[0]) && Number.isInteger(e[1])\n  && e[0] >= 0 && e[1] >= 0 && e[0] < N0 && e[1] < N0 && e[0] !== e[1]).slice(0, 5000);\nconst inn = nodes.map(() => 0), outn = nodes.map(() => 0);\nfor (const [a, b] of edges) { outn[a]++; inn[b]++; }\n\n// ---- click depth: breadth-first search from the homepage\nconst home = Number.isInteger(c.home) && c.home >= 0 && c.home < N0 ? c.home : 0;\nconst adj = nodes.map(() => []);\nfor (const [a, b] of edges) adj[a].push(b);\nnodes.forEach((n, i) => { if (n.r != null && n.r >= 0 && n.r < N0) adj[i].push(n.r); });\nconst depth = nodes.map(() => null);\nif (N0) {\n  depth[home] = 0;\n  const q = [home];\n  for (let k = 0; k < q.length; k++) for (const v of adj[q[k]]) if (depth[v] === null) { depth[v] = depth[q[k]] + 1; q.push(v); }\n  nodes.forEach((n, i) => { if (depth[i] === null && n.r === home) depth[i] = 0; });   // \"/\" redirecting to the real homepage\n}\n\n// ---- sections: first path segment; /en/blog/x counts as \"blog\". Top 7 get their own arm, the rest is \"…\"\nconst LG = /^[a-z]{2}(-[a-z]{2})?$/;\nconst clean = s => { let t = s; try { t = decodeURIComponent(s); } catch (e) {} return t.toLowerCase().replace(/[^a-z0-9._~-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24); };\nconst secOf = p => { const s = p.split('/').filter(Boolean); if (!s.length) return ''; if (LG.test(s[0])) return s.length > 1 ? clean(s[1]) : ''; return clean(s[0]); };\nconst sc = {};\nnodes.forEach(n => { const k = secOf(n.p); if (k) sc[k] = (sc[k] || 0) + 1; });\nconst top = Object.keys(sc).sort((a, b) => sc[b] - sc[a] || (a < b ? -1 : 1)).slice(0, 7);\nconst sections = ['/'].concat(top);\nconst hasMore = Object.keys(sc).length > top.length;\nif (hasMore) sections.push('…');\nconst secIdx = p => { const k = secOf(p); if (!k) return 0; const j = top.indexOf(k); return j >= 0 ? j + 1 : sections.length - 1; };\n\n// ---- findings\nconst fetched = nodes.filter(n => !n.o && n.s !== null && !n.rb);\nconst isBad = n => n.s !== null && n.s >= 400;\nconst broken = edges.filter(([a, b]) => isBad(nodes[b])).slice(0, 40);\nconst brokenLinks = edges.filter(([a, b]) => isBad(nodes[b])).length;\nconst brokenPages = nodes.filter(isBad).length;\nconst redirects = [];\nnodes.forEach((n, i) => { if (n.s !== null && n.s >= 300 && n.s < 400) redirects.push([i, n.r == null ? -1 : n.r]); });\nconst redirIdx = new Set(redirects.map(r => r[0]));\nconst redirectLinks = edges.filter(([a, b]) => redirIdx.has(b)).length;\nconst timeouts = nodes.filter(n => n.s === 0).length;\nconst noindex = nodes.filter(n => n.ni).length;\nconst deepAll = nodes.map((n, i) => i).filter(i => !nodes[i].o && depth[i] !== null && depth[i] > 3).sort((a, b) => depth[b] - depth[a]);\nconst deep = deepAll.slice(0, 30);\nconst ds = depth.filter((d, i) => d !== null && !nodes[i].o);\nconst max_depth = ds.length ? Math.max(...ds) : 0;\nconst avg_depth = ds.length ? Math.round(ds.reduce((a, b) => a + b, 0) / ds.length * 10) / 10 : 0;\nconst topIn = nodes.map((n, i) => i).filter(i => !nodes[i].o && inn[i] > 0).sort((a, b) => inn[b] - inn[a] || a - b).slice(0, 10);\nconst links_ext = nodes.reduce((a, n) => a + n.x, 0);\nconst ext = (Array.isArray(c.ext) ? c.ext : []).filter(e => Array.isArray(e) && typeof e[0] === 'string').slice(0, 8)\n  .map(e => [e[0].toLowerCase().replace(/[^a-z0-9.-]/g, '').slice(0, 60), Math.max(0, Number(e[1]) || 0)]);\n\n// ---- link health 0-100: broken links weigh most, then orphans, deep pages, redirects, timeouts\nlet h = 100;\nh -= Math.min(40, brokenPages * 6 + brokenLinks);\nh -= truncated ? Math.min(8, orphans_n * 0.05) : Math.min(20, orphans_n * 2);   // with the crawl limit they are only candidates\nh -= Math.min(15, N0 ? deepAll.length / N0 * 60 : 0);\nh -= Math.min(15, redirects.length * 2 + redirectLinks * 0.2);\nh -= Math.min(10, timeouts * 2);\nconst health = status === 'ok' && N0 ? Math.max(0, Math.min(100, Math.round(h))) : 0;\nconst grade = health >= 90 ? 'A' : health >= 75 ? 'B' : health >= 55 ? 'C' : health >= 35 ? 'D' : 'F';\n\n// ---- the name of the galaxy: 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 + '-Galaxie', en: core + ' Galaxy', it: 'Galassia ' + core }[lang];\n\nconst galaxy = { id: req.id, lang, domain, title, status, host: String(c.host || domain), origin: String(c.origin || ''), ms: Number(c.ms) || 0,\n  truncated, robots: !!(c.robots && c.robots.found), sitemap_n: c.sitemap ? Number(c.sitemap.n) || 0 : 0, health, grade,\n  stats: { pages: N0, fetched: fetched.length, int: edges.length, ext: links_ext, broken: brokenLinks, broken_pages: brokenPages,\n           redirects: redirects.length, redirect_links: redirectLinks, orphans: orphans_n, deep: deepAll.length, max_depth, avg_depth, timeouts, noindex },\n  sections,\n  // node: [path, status (null = not fetched), in, out, depth, section, flags (1 orphan, 2 noindex, 4 robots, 8 home), external links, title]\n  nodes: nodes.map((n, i) => [n.p, n.s, inn[i], outn[i], depth[i], secIdx(n.p), (n.o ? 1 : 0) | (n.ni ? 2 : 0) | (n.rb ? 4 : 0) | (i === home && N0 ? 8 : 0), n.x, n.t]),\n  edges, broken, redirects, deep, top: topIn, ext };\n\nconst facts = { galaxy: title, domain, pages_crawled: N0, crawl_limit_150_reached: truncated, internal_links: edges.length, external_links: links_ext,\n  broken_links: brokenLinks, broken_pages: nodes.filter(isBad).slice(0, 3).map(n => ({ path: n.p, status: n.s })),\n  redirects: redirects.length, links_to_redirects: redirectLinks,\n  orphan_pages: orphans_n, orphans_are_only_candidates_because_of_crawl_limit: truncated, example_orphan: (nodes.find(n => n.o) || {}).p || null,\n  deep_pages_over_3_clicks: deepAll.length, deepest_page: deep.length ? { path: nodes[deep[0]].p, clicks: depth[deep[0]] } : null,\n  max_click_depth: max_depth, average_click_depth: avg_depth,\n  top_pages_by_inbound_links: topIn.slice(0, 3).map(i => ({ path: nodes[i].p, inbound: inn[i] })),\n  sections: sections.slice(1, 6), robots_txt: galaxy.robots, sitemap_urls: galaxy.sitemap_n, link_health_score: health, grade };\nconst LANG = { de: 'German, informal \"du\"', en: 'English', it: 'Italian, informal \"tu\"' }[lang];\nconst prompt = 'You are the astronomer of the \"Link Galaxy\": you look at a website through a telescope. Pages are stars, the homepage is the '\n  + 'galactic core, internal links are luminous filaments, broken links (HTTP 4xx/5xx) are black holes, redirects are comets, orphan pages '\n  + '(in the sitemap, but no internal link reaches them) are planets drifting at the edge, deep pages are far-away stars. '\n  + `Write in ${LANG}. Observation data (use ONLY these numbers and paths, never invent any other number or fact): ${JSON.stringify(facts)}. `\n  + 'Answer with JSON only: {\"astro\":\"\"}. astro: 3-4 sentences, max 420 characters in total, an astronomer\\'s observation log: '\n  + 'poetic but precise, quote 3-4 numbers exactly, and end with the single most useful SEO fix (broken links first, then orphan pages, '\n  + 'then deep pages, then redirects), naming one concrete path if there is one. If orphans are only candidates, say \"possible\". '\n  + 'Friendly and calm, never alarming, never insulting. No hashtags, no emojis, no markdown.';\nreturn [{ json: { galaxy,\n  body: { model: 'openai/gpt-oss-120b', temperature: 0.7, max_tokens: 3000, reasoning_effort: 'low', response_format: { type: 'json_object' },\n          messages: [{ role: 'user', content: prompt }] } } }];"
   },
   "id": "c1",
   "name": "Sternkarte berechnen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    480,
    300
   ]
  },
  {
   "parameters": {
    "conditions": {
     "options": {
      "caseSensitive": true,
      "leftValue": "",
      "typeValidation": "strict"
     },
     "conditions": [
      {
       "id": "6f1c2b7e-0a4d-4c8e-9e21-0a1a7a000001",
       "leftValue": "={{ $json.galaxy.status }}",
       "rightValue": "ok",
       "operator": {
        "type": "string",
        "operation": "equals"
       }
      }
     ],
     "combinator": "and"
    },
    "options": {}
   },
   "id": "i1",
   "name": "Sicht frei?",
   "type": "n8n-nodes-base.if",
   "typeVersion": 2,
   "position": [
    700,
    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": "Astronomin (KI)",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    920,
    200
   ],
   "onError": "continueRegularOutput",
   "credentials": {
    "httpHeaderAuth": {
     "id": "",
     "name": "YOUR OpenAI-compatible API key (Header Auth: Authorization = Bearer …)"
    }
   }
  },
  {
   "parameters": {
    "mode": "runOnceForAllItems",
    "jsCode": "// Reads the AI observation; if it fails, is too short or too long, a template from the same numbers takes over.\n// A failed crawl (status nx / unreachable / robots / busy) gets no text: the page shows a message for the status.\nconst g = $('Sternkarte berechnen').first().json.galaxy;\nlet a = '';\ntry { const m = String($input.first().json.choices[0].message.content).match(/\\{[\\s\\S]*\\}/); a = String(JSON.parse(m[0]).astro || ''); } catch (e) {}\na = a.replace(/[*_#`]/g, '').replace(/\\s+/g, ' ').trim();\nif (a.length > 420) { const cut = a.slice(0, 420); const p = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? ')); a = p > 150 ? cut.slice(0, p + 1) : ''; }\nconst s = g.stats;\nconst fmt = n => String(n).replace(/\\B(?=(\\d{3})+(?!\\d))/g, g.lang === 'en' ? ',' : '.');\nconst fix = {\n  de: s.broken ? `Zuerst die ${fmt(s.broken)} defekten Links reparieren.` : s.orphans ? `Als Nächstes die ${s.orphans === 1 ? 'verwaiste Seite' : 'verwaisten Seiten'} intern verlinken.` : s.deep ? 'Tiefe Seiten näher an die Startseite holen.' : 'Weiter so, die Umlaufbahnen stimmen.',\n  en: s.broken ? `Fix the ${fmt(s.broken)} broken links first.` : s.orphans ? 'Next, link the orphan pages from inside the site.' : s.deep ? 'Bring the deep pages closer to the homepage.' : 'Keep it up, the orbits are fine.',\n  it: s.broken ? `Prima ripara i ${fmt(s.broken)} link rotti.` : s.orphans ? 'Poi collega le pagine orfane dall\\'interno del sito.' : s.deep ? 'Avvicina le pagine profonde alla homepage.' : 'Avanti così, le orbite sono in ordine.' }[g.lang];\nconst F = {\n  de: `Logbuch ${g.title}: ${fmt(s.pages)} Sterne, ${fmt(s.int)} leuchtende Filamente und ${s.broken_pages} Schwarze Löcher im Teleskop. Die fernste Seite liegt ${s.max_depth} Klicks vom Kern entfernt, Linkgesundheit ${g.health}/100. ${fix}`,\n  en: `Log ${g.title}: ${fmt(s.pages)} stars, ${fmt(s.int)} glowing filaments and ${s.broken_pages} black holes in the telescope. The farthest page lies ${s.max_depth} clicks from the core, link health ${g.health}/100. ${fix}`,\n  it: `Diario ${g.title}: ${fmt(s.pages)} stelle, ${fmt(s.int)} filamenti luminosi e ${s.broken_pages} buchi neri nel telescopio. La pagina più lontana è a ${s.max_depth} clic dal nucleo, salute dei link ${g.health}/100. ${fix}` }[g.lang];\nif (g.status !== 'ok') a = '';\nelse if (a.length < 60) a = F;\nreturn [{ json: { ...g, astro: a.slice(0, 440) } }];"
   },
   "id": "c2",
   "name": "Beobachtung prüfen",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    1140,
    300
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://YOUR-SITE.example/galaxy-ingest.php",
    "sendHeaders": true,
    "headerParameters": {
     "parameters": [
      {
       "name": "X-Galaxy-Token",
       "value": "YOUR-SECRET-TOKEN"
      }
     ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify($json) }}",
    "options": {
     "timeout": 60000
    }
   },
   "id": "p1",
   "name": "Galaxie an die Seite",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    1360,
    300
   ]
  }
 ],
 "connections": {
  "Besucher-Knopf (Webhook)": {
   "main": [
    [
     {
      "node": "Sternwarte: Website abtasten",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Sternwarte: Website abtasten": {
   "main": [
    [
     {
      "node": "Sternkarte berechnen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Sternkarte berechnen": {
   "main": [
    [
     {
      "node": "Sicht frei?",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Sicht frei?": {
   "main": [
    [
     {
      "node": "Astronomin (KI)",
      "type": "main",
      "index": 0
     }
    ],
    [
     {
      "node": "Beobachtung prüfen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Astronomin (KI)": {
   "main": [
    [
     {
      "node": "Beobachtung prüfen",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Beobachtung prüfen": {
   "main": [
    [
     {
      "node": "Galaxie an die Seite",
      "type": "main",
      "index": 0
     }
    ]
   ]
  }
 },
 "active": false,
 "settings": {
  "executionOrder": "v1",
  "timezone": "Europe/Berlin",
  "saveDataSuccessExecution": "none",
  "saveDataErrorExecution": "all"
 },
 "pinData": {}
}