Files
Marc Mintel 5da88356a8
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 35s
Monorepo Pipeline / 🧪 Test (push) Failing after 35s
Monorepo Pipeline / 🏗️ Build (push) Failing after 12s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
feat: migrate npm registry from Verdaccio to Gitea Packages
2026-02-27 00:12:00 +01:00

150 lines
5.6 KiB
TypeScript

// ============================================================================
// @mintel/page-audit — AI Report Generator
// Uses Gemini Pro (via OpenRouter) to synthesize DataForSEO data into
// a structured IST-analysis report in German.
// ============================================================================
import type { DomainAuditResult, AuditReport, PageAuditData, AuditIssue } from "./types.js";
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
const REPORT_MODEL = "google/gemini-3.1-pro-preview";
/**
* Generate an AI-powered IST-analysis report from audit data.
*/
export async function generateAuditReport(
audit: DomainAuditResult,
openrouterKey: string,
): Promise<AuditReport> {
const summary = buildAuditSummary(audit);
const systemPrompt = `
Du bist ein Senior SEO- und UX-Stratege. Analysiere die technischen Audit-Daten einer Website
und erstelle einen präzisen IST-Analyse-Bericht auf DEUTSCH.
Stil:
- Faktisch, direkt, kein Bullshit
- Konkrete Handlungsempfehlungen, keine vagen Floskeln
- Technik-verständlich für Entscheider (nicht für Entwickler)
Output: JSON (kein Markdown drumherum)
`;
const userPrompt = `
Website: ${audit.domain}
Seiten gecrawlt: ${audit.totalPages}
Audit-Datum: ${audit.auditedAt}
=== TECHNISCHE AUSWERTUNG ===
${summary}
=== TOP-ISSUES ===
${audit.topIssues.map((i) => `[${i.severity.toUpperCase()}] ${i.message}${i.count ? ` (${i.count}x)` : ""}`).join("\n")}
Erstelle jetzt den IST-Analyse-Report als JSON:
{
"executiveSummary": string (2-3 Sätze über den aktuellen Zustand der Website),
"strengths": string[] (max 4, was gut läuft),
"criticalIssues": string[] (max 5, sofort zu beheben),
"quickWins": string[] (max 4, einfach umzusetzen mit großer Wirkung),
"strategicRecommendations": string[] (max 4, mittel-/langfristig),
"seoScore": number (0-100, realistisch),
"uxScore": number (0-100, realistisch),
"performanceScore": number (0-100, realistisch),
"overallHealth": "critical" | "needs-work" | "good" | "excellent"
}
`;
const response = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${openrouterKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: REPORT_MODEL,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
}),
});
if (!response.ok) {
throw new Error(`LLM request failed: ${response.status} ${await response.text()}`);
}
const json = await response.json();
const content = json.choices?.[0]?.message?.content || "{}";
// Clean up markdown JSON wrappers if present
const cleaned = content.replace(/^```(?:json)?\n?/m, "").replace(/```$/m, "").trim();
try {
return JSON.parse(cleaned) as AuditReport;
} catch {
throw new Error(`Could not parse AI report: ${cleaned.slice(0, 200)}`);
}
}
/**
* Build a human-readable text summary of the audit data for the LLM prompt.
*/
function buildAuditSummary(audit: DomainAuditResult): string {
const pages = audit.pages;
const brokenPages = pages.filter((p) => p.statusCode >= 400);
const noTitle = pages.filter((p) => !p.pageTitle);
const noDesc = pages.filter((p) => !p.metaDescription);
const noH1 = pages.filter((p) => !p.h1);
const notIndexable = pages.filter((p) => !p.seo.isIndexable);
const noViewport = pages.filter((p) => !p.seo.hasViewport);
const slowPages = pages.filter((p) => p.loadTime !== null && p.loadTime > 3000);
const imagesWithoutAlt = pages.reduce((sum, p) => sum + p.images.missingAlt, 0);
const totalImages = pages.reduce((sum, p) => sum + p.images.total, 0);
const avgLoad = pages
.filter((p) => p.loadTime !== null)
.reduce((sum, p, _, arr) => sum + (p.loadTime || 0) / arr.length, 0);
const lines = [
`Seiten gesamt: ${pages.length}`,
`Seiten mit Fehler (4xx/5xx): ${brokenPages.length}`,
`Seiten ohne <title>: ${noTitle.length}`,
`Seiten ohne Meta-Description: ${noDesc.length}`,
`Seiten ohne H1: ${noH1.length}`,
`Nicht-indexierbare Seiten: ${notIndexable.length}`,
`Seiten ohne Viewport-Meta: ${noViewport.length}`,
`Bilder gesamt: ${totalImages}, davon ohne alt-Attribut: ${imagesWithoutAlt}`,
`Langsame Seiten (>3s): ${slowPages.length}`,
`Ø Ladezeit: ${avgLoad > 0 ? `${(avgLoad / 1000).toFixed(1)}s` : "unbekannt"}`,
];
// Core Web Vitals (from first valid page)
const lcpPages = pages.filter((p) => p.performance.lcp !== null);
if (lcpPages.length > 0) {
const avgLcp = lcpPages.reduce((s, p) => s + (p.performance.lcp || 0), 0) / lcpPages.length;
lines.push(`Ø LCP: ${(avgLcp / 1000).toFixed(1)}s (Ziel: <2.5s)`);
}
const clsPages = pages.filter((p) => p.performance.cls !== null);
if (clsPages.length > 0) {
const avgCls = clsPages.reduce((s, p) => s + (p.performance.cls || 0), 0) / clsPages.length;
lines.push(`Ø CLS: ${avgCls.toFixed(3)} (Ziel: <0.1)`);
}
// Top pages by issues
const worstPages = [...pages]
.sort((a, b) => b.issues.length - a.issues.length)
.slice(0, 5);
if (worstPages.length > 0) {
lines.push("\nSeiten mit den meisten Problemen:");
for (const page of worstPages) {
lines.push(` ${page.url}: ${page.issues.length} Issues (${page.issues.map((i) => i.code).join(", ")})`);
}
}
return lines.join("\n");
}