Files
webdev-pipeline/v2_elemente/audit.ts

226 lines
7.7 KiB
TypeScript

/**
* convex/lib/audit.ts
*
* Externe Clients und reine Helfer für die Audit-Pipeline. Wird nur aus
* Convex *Actions* aufgerufen (fetch). Alles dependency-frei (kein "use node"),
* damit audits.ts Queries/Mutations/Actions in einer Datei halten kann.
*/
// ---- Kosten (Quelle: PitchFast_Pricing_Modell.xlsx) --------------------------
export const AUDIT_COSTS = {
llmInputPerMTokUsd: 0.75,
llmOutputPerMTokUsd: 4.5,
screenshotUsd: 0.0085,
jinaUsd: 0.003,
};
export function estimateAuditCostUsd(p: {
inputTokens: number;
outputTokens: number;
screenshots: number;
}): number {
return (
(p.inputTokens / 1_000_000) * AUDIT_COSTS.llmInputPerMTokUsd +
(p.outputTokens / 1_000_000) * AUDIT_COSTS.llmOutputPerMTokUsd +
p.screenshots * AUDIT_COSTS.screenshotUsd +
AUDIT_COSTS.jinaUsd
);
}
// ---- ScreenshotOne -----------------------------------------------------------
const SCREENSHOTONE_URL = "https://api.screenshotone.com/take";
/** Einen Screenshot holen (PNG-Bytes). Cookie-Banner/Ads werden gefiltert. */
export async function takeScreenshot(args: {
accessKey: string;
url: string;
device: "desktop" | "mobile";
}): Promise<Uint8Array> {
const params = new URLSearchParams({
access_key: args.accessKey,
url: args.url,
format: "png",
full_page: "true",
block_cookie_banners: "true",
block_ads: "true",
block_banners_by_heuristics: "true",
viewport_width: args.device === "mobile" ? "390" : "1280",
viewport_height: args.device === "mobile" ? "844" : "900",
device_scale_factor: args.device === "mobile" ? "2" : "1",
});
const res = await fetch(`${SCREENSHOTONE_URL}?${params.toString()}`);
if (!res.ok) {
throw new Error(`ScreenshotOne ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
return new Uint8Array(await res.arrayBuffer());
}
export function toDataUrl(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return `data:image/png;base64,${btoa(bin)}`;
}
// ---- Jina AI Reader (HTML → Markdown) ----------------------------------------
const COMMON_SUBPATHS = ["kontakt", "impressum", "leistungen", "ueber-uns"];
const MARKDOWN_CHAR_CAP = 12000;
/** Startseite + (falls vorhanden) relevante Unterseiten als Markdown. */
export async function fetchSiteMarkdown(args: {
jinaKey?: string;
baseUrl: string;
}): Promise<{ markdown: string; pages: string[] }> {
const headers: Record<string, string> = { "X-Return-Format": "markdown" };
if (args.jinaKey) headers.Authorization = `Bearer ${args.jinaKey}`;
const base = args.baseUrl.replace(/\/$/, "");
const urls = [base, ...COMMON_SUBPATHS.map((p) => `${base}/${p}`)];
const pages: string[] = [];
let combined = "";
for (const u of urls) {
if (combined.length >= MARKDOWN_CHAR_CAP) break;
try {
const res = await fetch(`https://r.jina.ai/${u}`, { headers });
if (!res.ok) continue;
const md = await res.text();
if (md && md.trim().length > 50) {
pages.push(u);
combined += `\n\n## Seite: ${u}\n${md}`;
}
} catch {
// Unterseite existiert nicht / Fehler → überspringen
}
}
return { markdown: combined.slice(0, MARKDOWN_CHAR_CAP), pages };
}
// ---- PageSpeed Insights ------------------------------------------------------
export async function fetchPageSpeed(args: {
googleKey: string;
url: string;
}): Promise<any> {
const params = new URLSearchParams({
url: args.url,
key: args.googleKey,
strategy: "mobile",
});
params.append("category", "performance");
params.append("category", "accessibility");
const res = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params.toString()}`,
);
if (!res.ok) throw new Error(`PageSpeed ${res.status}`);
const data = await res.json();
const lh = data.lighthouseResult ?? {};
const audits = lh.audits ?? {};
return {
performanceScore: lh.categories?.performance?.score ?? null,
accessibilityScore: lh.categories?.accessibility?.score ?? null,
lcp: audits["largest-contentful-paint"]?.displayValue ?? null,
cls: audits["cumulative-layout-shift"]?.displayValue ?? null,
inp: audits["interaction-to-next-paint"]?.displayValue ?? null,
};
}
// ---- Struktur-Check (regex, dependency-frei) ---------------------------------
export function parseStructuralSignals(html: string, url: string) {
const m = (re: RegExp) => (html.match(re)?.[1] ?? "").trim();
return {
title: m(/<title[^>]*>([^<]*)<\/title>/i) || null,
metaDescription:
m(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ||
m(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i) ||
null,
h1Count: (html.match(/<h1[\s>]/gi) ?? []).length,
hasViewport: /<meta[^>]+name=["']viewport["']/i.test(html),
isHttps: url.startsWith("https://"),
};
}
// ---- Multimodale Auswertung (OpenRouter, GPT-5.4 mini) -----------------------
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const MODEL = "openai/gpt-5.4-mini"; // OpenRouter-Slug ggf. verifizieren
export function buildSystemPrompt(skillsRegistry: string): string {
return [
"Du bist der Audit-Agent von PitchFast. Du beurteilst die Website eines lokalen",
"Dienstleisters anhand der folgenden Skill-Registry. Wähle alle zutreffenden",
"Skills, deren benötigte Eingaben vorliegen, und erzeuge Findings.",
"",
"VOICE: Ich-Form, auf Augenhöhe, respektvoll, ohne Floskeln, ohne Dringlichkeit,",
"ohne Preise. Beobachtung statt Urteil. Übersetze jeden Befund in Kundennutzen.",
"Severity ist INTERN — niemals im öffentlichen Text nennen, keine Scores/Ampeln.",
"",
"Gib AUSSCHLIESSLICH valides JSON zurück, ohne Markdown-Fences, exakt in diesem Schema:",
`{
"findings": [{"skill_id": string, "observation": string, "customer_benefit": string,
"public_phrasing": string, "severity": 1|2|3, "evidence": string}],
"finalSummary": string, // intern, vollständig
"publicAuditText": string, // extern, voice-konform, wenige konkrete Punkte
"emailSubject": string,
"emailBody": string, // Ich-Form, persönlich, ohne Preise
"phoneScript": string,
"ctaType": string, // z.B. "anruf" | "termin" | "rueckruf"
"usedSkills": [string]
}`,
"",
"=== SKILL-REGISTRY (skills.md) ===",
skillsRegistry,
].join("\n");
}
export async function runMultimodalAnalysis(args: {
openRouterKey: string;
systemPrompt: string;
textContext: string;
desktopDataUrl: string;
mobileDataUrl: string;
}): Promise<{ json: any; inputTokens: number; outputTokens: number }> {
const res = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${args.openRouterKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: args.systemPrompt },
{
role: "user",
content: [
{ type: "text", text: args.textContext },
{ type: "image_url", image_url: { url: args.desktopDataUrl } },
{ type: "image_url", image_url: { url: args.mobileDataUrl } },
],
},
],
}),
});
if (!res.ok) {
throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const data = await res.json();
const content: string = data.choices?.[0]?.message?.content ?? "{}";
const clean = content.replace(/```json|```/g, "").trim();
let json: any;
try {
json = JSON.parse(clean);
} catch {
throw new Error("LLM-Antwort war kein valides JSON");
}
return {
json,
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
};
}