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
150 lines
5.6 KiB
TypeScript
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");
|
|
}
|