Initial commit: Visigine (Vite client + Express/SQLite backend)

Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:06:48 +02:00
commit e344f1b7e7
88 changed files with 11764 additions and 0 deletions

27
server/checks/ai-bots.js Normal file
View File

@@ -0,0 +1,27 @@
// Group A — AI bots referenced in robots.txt. Substring match is intentional:
// canonical bot tokens are case-sensitive and stable.
const BOTS = [
{ id: 'ai-bots.gptbot', token: 'GPTBot', severity: 'high', title: 'GPTBot nicht in robots.txt' },
{ id: 'ai-bots.claudebot', token: 'ClaudeBot', severity: 'high', title: 'ClaudeBot nicht in robots.txt' },
{ id: 'ai-bots.oai-searchbot', token: 'OAI-SearchBot', severity: 'high', title: 'OAI-SearchBot nicht in robots.txt' },
{ id: 'ai-bots.perplexitybot', token: 'PerplexityBot', severity: 'high', title: 'PerplexityBot nicht in robots.txt' },
{ id: 'ai-bots.bingbot', token: 'Bingbot', severity: 'high', title: 'Bingbot nicht in robots.txt' },
{ id: 'ai-bots.google-extended', token: 'Google-Extended', severity: 'medium', title: 'Google-Extended nicht in robots.txt' },
{ id: 'ai-bots.googleother', token: 'GoogleOther', severity: 'medium', title: 'GoogleOther nicht in robots.txt' },
{ id: 'ai-bots.applebot-extended', token: 'Applebot-Extended', severity: 'medium', title: 'Applebot-Extended nicht in robots.txt' },
{ id: 'ai-bots.meta-externalagent', token: 'Meta-ExternalAgent', severity: 'medium', title: 'Meta-ExternalAgent nicht in robots.txt' },
{ id: 'ai-bots.ccbot', token: 'CCBot', severity: 'medium', title: 'CCBot (Common Crawl) nicht in robots.txt' },
{ id: 'ai-bots.bytespider', token: 'Bytespider', severity: 'low', title: 'Bytespider nicht in robots.txt' },
{ id: 'ai-bots.duckassistbot', token: 'DuckAssistBot', severity: 'low', title: 'DuckAssistBot nicht in robots.txt' },
{ id: 'ai-bots.chatgpt-user', token: 'ChatGPT-User', severity: 'low', title: 'ChatGPT-User nicht in robots.txt' },
]
export function runAiBotsChecks({ robotsTxt }) {
const rb = robotsTxt || ''
return BOTS.map(({ id, token, severity, title }) => ({
id,
title,
severity,
passed: rb.includes(token),
}))
}

View File

@@ -0,0 +1,43 @@
// Group F — probe the target with AI bot user agents to detect WAF/CDN blocking.
const BLOCKED_STATUSES = new Set([403, 429, 451, 503, 0])
const PROBES = [
{
id: 'ai-reach.claudebot',
title: 'ClaudeBot wird blockiert (Cloudflare oder Firewall)',
userAgent: 'ClaudeBot/1.0 (+https://www.anthropic.com)',
},
{
id: 'ai-reach.gptbot',
title: 'GPTBot wird blockiert (Cloudflare oder Firewall)',
userAgent: 'GPTBot/1.0 (+https://openai.com/gptbot)',
},
]
export async function runAiReachabilityChecks({ targetUrl, mainStatus, fetchPage }) {
// If the baseline fetch already failed, the probes would be misleading —
// mark both as failed but skip the network calls.
if (mainStatus !== 200) {
return PROBES.map((p) => ({
id: p.id,
title: p.title,
severity: 'high',
passed: false,
}))
}
const probeResults = await Promise.all(
PROBES.map((p) => fetchPage(targetUrl, { userAgent: p.userAgent }))
)
return PROBES.map((p, i) => {
const status = probeResults[i].status
const blocked = BLOCKED_STATUSES.has(status)
return {
id: p.id,
title: p.title,
severity: 'high',
passed: !blocked,
}
})
}

23
server/checks/index.js Normal file
View File

@@ -0,0 +1,23 @@
// Orchestrates all check groups. Async groups run in parallel; sync groups append.
import { runAiBotsChecks } from './ai-bots.js'
import { runLlmsChecks } from './llms-txt.js'
import { runJsonLdChecks } from './json-ld.js'
import { runMetaChecks } from './meta-tags.js'
import { runTechnicalChecks } from './technical.js'
import { runAiReachabilityChecks } from './ai-reachability.js'
export async function runAllChecks(context) {
const [llms, technical, aiReach] = await Promise.all([
runLlmsChecks(context),
runTechnicalChecks(context),
runAiReachabilityChecks(context),
])
return [
...runAiBotsChecks(context),
...llms,
...runJsonLdChecks(context),
...runMetaChecks(context),
...technical,
...aiReach,
]
}

82
server/checks/json-ld.js Normal file
View File

@@ -0,0 +1,82 @@
// Group C — schema.org / JSON-LD coverage. Validity is checked block-by-block;
// the remaining matches run against the concatenated raw string for resilience.
export function runJsonLdChecks({ jsonLdBlocks, jsonLdJoined }) {
const blocks = jsonLdBlocks || []
const jl = jsonLdJoined || ''
let validity = true
if (blocks.length > 0) {
validity = blocks.some((b) => {
try { JSON.parse(b); return true } catch { return false }
})
}
return [
{
id: 'jsonld.valid',
title: 'JSON-LD: ungültiges JSON',
severity: 'high',
passed: validity,
},
{
id: 'jsonld.organization',
title: 'JSON-LD: kein LocalBusiness/Organization',
severity: 'high',
passed: /"@type"\s*:\s*"?(LocalBusiness|Organization)/i.test(jl),
},
{
id: 'jsonld.faqpage',
title: 'JSON-LD: kein FAQPage-Schema',
severity: 'medium',
passed: jl.includes('FAQPage'),
},
{
id: 'jsonld.sameas',
title: 'JSON-LD: kein sameAs mit externen URLs',
severity: 'medium',
passed: /"sameAs"/.test(jl) && /https?:\/\//.test(jl),
},
{
id: 'jsonld.openinghours',
title: 'JSON-LD: keine Öffnungszeiten',
severity: 'medium',
passed: jl.includes('openingHours'),
},
{
id: 'jsonld.breadcrumb',
title: 'JSON-LD: keine BreadcrumbList',
severity: 'low',
passed: jl.includes('BreadcrumbList'),
},
{
id: 'jsonld.website',
title: 'JSON-LD: kein WebSite-Schema',
severity: 'low',
passed: /"@type"\s*:\s*"?WebSite/.test(jl),
},
{
id: 'jsonld.address',
title: 'JSON-LD: keine PostalAddress',
severity: 'medium',
passed: jl.includes('PostalAddress'),
},
{
id: 'jsonld.telephone',
title: 'JSON-LD: keine Telefonnummer im Schema',
severity: 'low',
passed: /"telephone"/.test(jl),
},
{
id: 'jsonld.service-product',
title: 'JSON-LD: kein Service- oder Product-Schema',
severity: 'low',
passed: /"@type"\s*:\s*"?(Service|Product)/i.test(jl),
},
{
id: 'jsonld.article',
title: 'JSON-LD: kein Article/BlogPosting-Schema',
severity: 'low',
passed: /"@type"\s*:\s*"?(Article|BlogPosting)/i.test(jl),
},
]
}

50
server/checks/llms-txt.js Normal file
View File

@@ -0,0 +1,50 @@
// Group B — llms.txt presence, structure, and accessibility.
export async function runLlmsChecks({ baseUrl, llmsTxt, llmsStatus, robotsTxt, fetchPage }) {
const ll = llmsTxt || ''
const rb = robotsTxt || ''
const present = (llmsStatus === 200) && ll.length > 0
const results = [
{
id: 'llms.present',
title: 'llms.txt fehlt',
severity: 'high',
passed: present,
},
{
id: 'llms.structured',
title: 'llms.txt: keine strukturierten Metadaten',
severity: 'high',
passed: /^[-*]\s*\w+:/m.test(ll),
},
{
id: 'llms.substantial',
title: 'llms.txt zu kurz (unter 500 Zeichen)',
severity: 'medium',
passed: ll.length >= 500,
},
{
id: 'llms.not-disallowed',
title: 'llms.txt ist in robots.txt blockiert',
severity: 'high',
passed: !/Disallow:\s*\/llms\.txt/i.test(rb),
},
]
// llms-full.txt is fetched lazily; only report fail when the base file is reachable.
let fullPassed = false
try {
const full = await fetchPage(`${baseUrl}/llms-full.txt`)
fullPassed = full.status === 200 && (full.body || '').length > 0
} catch {
fullPassed = false
}
results.push({
id: 'llms.full-version',
title: 'llms-full.txt fehlt (erweiterte Version)',
severity: 'low',
passed: fullPassed,
})
return results
}

View File

@@ -0,0 +1,68 @@
// Group D — head / meta / open graph / twitter / canonical / lang.
export function runMetaChecks({ html, headHtml }) {
const hh = headHtml || ''
const full = html || ''
const descMatch = hh.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
const descContent = descMatch ? descMatch[1].trim() : ''
const titleMatch = hh.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
const titleText = titleMatch ? titleMatch[1].trim() : ''
return [
{
id: 'meta.canonical',
title: 'Canonical-Tag fehlt',
severity: 'low',
passed: /rel=["']canonical["']/i.test(hh),
},
{
id: 'meta.og-title',
title: 'og:title fehlt',
severity: 'low',
passed: /property=["']og:title["']/i.test(hh),
},
{
id: 'meta.og-image',
title: 'og:image fehlt',
severity: 'low',
passed: /property=["']og:image["']/i.test(hh),
},
{
id: 'meta.og-description',
title: 'og:description fehlt',
severity: 'low',
passed: /property=["']og:description["']/i.test(hh),
},
{
id: 'meta.og-type',
title: 'og:type fehlt',
severity: 'low',
passed: /property=["']og:type["']/i.test(hh),
},
{
id: 'meta.twitter-card',
title: 'twitter:card fehlt',
severity: 'low',
passed: /name=["']twitter:card["']/i.test(hh),
},
{
id: 'meta.description',
title: 'Meta-Description fehlt oder zu kurz',
severity: 'medium',
passed: descContent.length >= 50,
},
{
id: 'meta.title-length',
title: 'Title fehlt oder ungeeignete Länge',
severity: 'medium',
passed: titleText.length >= 20 && titleText.length <= 70,
},
{
id: 'meta.lang-attribute',
title: '<html lang="..."> Attribut fehlt',
severity: 'medium',
passed: /<html[^>]*\slang=/i.test(full),
},
]
}

View File

@@ -0,0 +1,51 @@
// Group E — technical signals: sitemap, HSTS, viewport, H1.
export async function runTechnicalChecks({ baseUrl, html, headHtml, robotsTxt, responseHeaders, fetchPage }) {
const rb = robotsTxt || ''
const hh = headHtml || ''
const full = html || ''
const headers = responseHeaders || {}
let sitemapReachable = false
try {
const sm = await fetchPage(`${baseUrl}/sitemap.xml`)
const body = (sm.body || '').trimStart()
sitemapReachable =
sm.status === 200 &&
(body.startsWith('<?xml') || body.startsWith('<urlset') || body.startsWith('<sitemapindex'))
} catch {
sitemapReachable = false
}
return [
{
id: 'tech.sitemap-referenced',
title: 'Sitemap in robots.txt nicht referenziert',
severity: 'low',
passed: /sitemap:/i.test(rb),
},
{
id: 'tech.sitemap-reachable',
title: 'Sitemap.xml nicht erreichbar',
severity: 'medium',
passed: sitemapReachable,
},
{
id: 'tech.hsts',
title: 'HSTS-Header fehlt',
severity: 'low',
passed: Boolean(headers['strict-transport-security']),
},
{
id: 'tech.viewport',
title: 'Viewport-Meta-Tag fehlt (Mobile)',
severity: 'medium',
passed: /name=["']viewport["']/i.test(hh),
},
{
id: 'tech.h1',
title: 'H1-Überschrift fehlt',
severity: 'medium',
passed: /<h1[\s>]/i.test(full),
},
]
}