226 lines
7.7 KiB
TypeScript
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,
|
|
};
|
|
}
|