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:
27
server/checks/ai-bots.js
Normal file
27
server/checks/ai-bots.js
Normal 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),
|
||||
}))
|
||||
}
|
||||
43
server/checks/ai-reachability.js
Normal file
43
server/checks/ai-reachability.js
Normal 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
23
server/checks/index.js
Normal 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
82
server/checks/json-ld.js
Normal 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
50
server/checks/llms-txt.js
Normal 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
|
||||
}
|
||||
68
server/checks/meta-tags.js
Normal file
68
server/checks/meta-tags.js
Normal 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),
|
||||
},
|
||||
]
|
||||
}
|
||||
51
server/checks/technical.js
Normal file
51
server/checks/technical.js
Normal 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),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user