359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
"use node";
|
|
|
|
import { internal } from "./_generated/api";
|
|
import { internalAction } from "./_generated/server";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import type { ActionCtx } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import {
|
|
classifyPageSpeedError,
|
|
fetchPageSpeedResult,
|
|
normalizePageSpeedResult,
|
|
type PageSpeedErrorType,
|
|
} from "../lib/pagespeed-insights";
|
|
|
|
const STRATEGIES = ["mobile", "desktop"] as const;
|
|
export const MAX_RAW_PAGESPEED_BYTES = 1_000_000;
|
|
const RAW_PAGESPEED_BYTES_SUMMARY =
|
|
"PageSpeed-Rohdaten sind groesser als das interne Speicherlimit.";
|
|
const DEFAULT_PAGESPEED_TIMEOUT_MS = 60_000;
|
|
const MIN_PAGESPEED_TIMEOUT_MS = 10_000;
|
|
const MAX_PAGESPEED_TIMEOUT_MS = 120_000;
|
|
|
|
function toPersistedPageSpeedNormalizedResult(
|
|
normalized: ReturnType<typeof normalizePageSpeedResult>,
|
|
) {
|
|
return {
|
|
...(normalized.scores ? { scores: normalized.scores } : {}),
|
|
metrics: normalized.metrics,
|
|
opportunities: normalized.opportunities,
|
|
implications: normalized.implications,
|
|
};
|
|
}
|
|
|
|
function parsePageSpeedTimeoutMs(raw: string | undefined): number {
|
|
if (!raw) {
|
|
return DEFAULT_PAGESPEED_TIMEOUT_MS;
|
|
}
|
|
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_PAGESPEED_TIMEOUT_MS;
|
|
}
|
|
|
|
return Math.min(
|
|
Math.max(parsed, MIN_PAGESPEED_TIMEOUT_MS),
|
|
MAX_PAGESPEED_TIMEOUT_MS,
|
|
);
|
|
}
|
|
|
|
function resolvePageSpeedTimeoutMs() {
|
|
return parsePageSpeedTimeoutMs(process.env.PAGESPEED_TIMEOUT_MS);
|
|
}
|
|
|
|
function isPageSpeedErrorType(value: unknown): value is PageSpeedErrorType {
|
|
return (
|
|
value === "quota" ||
|
|
value === "timeout" ||
|
|
value === "unavailable" ||
|
|
value === "invalid_url" ||
|
|
value === "api_error" ||
|
|
value === "unknown"
|
|
);
|
|
}
|
|
|
|
function sanitizeValue(value: string, secret?: string | null) {
|
|
if (!secret || !value) {
|
|
return value;
|
|
}
|
|
const escapedSecret = secret.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
return value.replace(new RegExp(escapedSecret, "g"), "[REDACTED]");
|
|
}
|
|
|
|
function classifyPageSpeedFailure(input: unknown, apiKey?: string | null) {
|
|
const directType =
|
|
typeof input === "object" &&
|
|
input !== null &&
|
|
"errorType" in input &&
|
|
(input as { errorType?: unknown }).errorType;
|
|
|
|
const normalizedType = isPageSpeedErrorType(directType) ? directType : null;
|
|
if (normalizedType) {
|
|
const message =
|
|
input instanceof Error && input.message
|
|
? input.message
|
|
: typeof input === "string"
|
|
? input
|
|
: "PageSpeed-Analyse fehlgeschlagen.";
|
|
return {
|
|
errorType: normalizedType,
|
|
errorSummary: sanitizeValue(message, apiKey),
|
|
};
|
|
}
|
|
|
|
const classified = classifyPageSpeedError({
|
|
error: input,
|
|
});
|
|
const errorSummary = sanitizeValue(classified.message, apiKey);
|
|
|
|
return {
|
|
errorType: classified.errorType,
|
|
errorSummary,
|
|
};
|
|
}
|
|
|
|
type StartedPageSpeedAudit = {
|
|
lead: {
|
|
_id: Id<"leads">;
|
|
websiteUrl: string;
|
|
};
|
|
auditId?: Id<"audits">;
|
|
};
|
|
|
|
async function queueAuditGenerationAfterPageSpeed(
|
|
ctx: ActionCtx,
|
|
runId: Id<"agentRuns">,
|
|
started: StartedPageSpeedAudit,
|
|
) {
|
|
try {
|
|
await ctx.runMutation(internal.auditGeneration.queueLeadAuditGeneration, {
|
|
leadId: started.lead._id,
|
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
parentRunId: runId,
|
|
});
|
|
} catch (auditQueueError) {
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId,
|
|
level: "warning",
|
|
message: "Audit-Generierung konnte nicht in die Warteschlange gesetzt werden.",
|
|
details: [
|
|
{ label: "Lead", value: started.lead._id },
|
|
{
|
|
label: "Fehler",
|
|
value: auditQueueError instanceof Error
|
|
? auditQueueError.message
|
|
: String(auditQueueError),
|
|
source: "audit_generation_queue",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
|
|
export const processPageSpeedAudit = internalAction({
|
|
args: {
|
|
runId: v.id("agentRuns"),
|
|
queueGeneration: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const apiKeyRaw = process.env.PAGESPEED_API_KEY?.trim();
|
|
const apiKey = apiKeyRaw ? apiKeyRaw : undefined;
|
|
|
|
let started: StartedPageSpeedAudit | null = null;
|
|
|
|
try {
|
|
started = await ctx.runMutation(internal.pageSpeed.startPageSpeedAuditRun, {
|
|
runId: args.runId,
|
|
});
|
|
} catch (error) {
|
|
const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw);
|
|
|
|
await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, {
|
|
runId: args.runId,
|
|
status: "failed",
|
|
errors: 1,
|
|
errorSummary,
|
|
});
|
|
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId: args.runId,
|
|
level: "error",
|
|
message: "PageSpeed-Analyse fehlgeschlagen.",
|
|
details: [{ label: "Fehler", value: errorSummary }],
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
if (!started) {
|
|
return null;
|
|
}
|
|
|
|
const sourceUrl = started.lead.websiteUrl;
|
|
const timeoutMs = resolvePageSpeedTimeoutMs();
|
|
|
|
let failedStrategies = 0;
|
|
let succeededStrategies = 0;
|
|
|
|
try {
|
|
const strategyResults = await Promise.all(
|
|
STRATEGIES.map(async (strategy) => {
|
|
const fetchedAt = Date.now();
|
|
try {
|
|
const raw = await fetchPageSpeedResult({
|
|
url: sourceUrl,
|
|
strategy,
|
|
apiKey,
|
|
timeoutMs,
|
|
});
|
|
const rawJson = JSON.stringify(raw) ?? "null";
|
|
const rawJsonBytes = new TextEncoder().encode(rawJson).byteLength;
|
|
if (rawJsonBytes > MAX_RAW_PAGESPEED_BYTES) {
|
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
|
leadId: started.lead._id,
|
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
runId: args.runId,
|
|
strategy,
|
|
status: "failed",
|
|
sourceUrl,
|
|
errorType: "api_error",
|
|
errorSummary: RAW_PAGESPEED_BYTES_SUMMARY,
|
|
fetchedAt,
|
|
});
|
|
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId: args.runId,
|
|
level: "warning",
|
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
|
details: [
|
|
{ label: "Strategie", value: strategy },
|
|
{
|
|
label: "Fehler",
|
|
value: RAW_PAGESPEED_BYTES_SUMMARY,
|
|
},
|
|
],
|
|
});
|
|
|
|
return "failed" as const;
|
|
}
|
|
|
|
const rawStorageId = await ctx.storage.store(
|
|
new Blob([rawJson], { type: "application/json" }),
|
|
);
|
|
const normalized = normalizePageSpeedResult({
|
|
strategy,
|
|
sourceUrl,
|
|
raw,
|
|
});
|
|
|
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
|
leadId: started.lead._id,
|
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
runId: args.runId,
|
|
strategy,
|
|
status: "succeeded",
|
|
sourceUrl,
|
|
finalUrl: normalized.finalUrl,
|
|
rawStorageId,
|
|
fetchedAt,
|
|
normalized: toPersistedPageSpeedNormalizedResult(normalized),
|
|
});
|
|
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId: args.runId,
|
|
level: "info",
|
|
message: `PageSpeed-Analyse für ${strategy} abgeschlossen.`,
|
|
details: [{ label: "Strategie", value: strategy }],
|
|
});
|
|
return "succeeded" as const;
|
|
} catch (error) {
|
|
const { errorType, errorSummary } = classifyPageSpeedFailure(
|
|
error,
|
|
apiKeyRaw,
|
|
);
|
|
|
|
await ctx.runMutation(internal.pageSpeed.persistPageSpeedResult, {
|
|
leadId: started.lead._id,
|
|
...(started.auditId ? { auditId: started.auditId } : {}),
|
|
runId: args.runId,
|
|
strategy,
|
|
status: "failed",
|
|
sourceUrl,
|
|
errorType,
|
|
errorSummary,
|
|
fetchedAt,
|
|
});
|
|
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId: args.runId,
|
|
level: "warning",
|
|
message: `PageSpeed-Analyse für ${strategy} fehlgeschlagen.`,
|
|
details: [
|
|
{ label: "Strategie", value: strategy },
|
|
{ label: "Fehler", value: errorSummary },
|
|
],
|
|
});
|
|
return "failed" as const;
|
|
}
|
|
}),
|
|
);
|
|
|
|
succeededStrategies = strategyResults.filter(
|
|
(result) => result === "succeeded",
|
|
).length;
|
|
failedStrategies = strategyResults.length - succeededStrategies;
|
|
|
|
const status = succeededStrategies > 0 ? "succeeded" : "failed";
|
|
const errors = failedStrategies;
|
|
await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, {
|
|
runId: args.runId,
|
|
status,
|
|
errors,
|
|
errorSummary:
|
|
status === "failed" && errors > 0
|
|
? "Ein oder mehrere PageSpeed-Strategien konnten nicht ausgeführt werden."
|
|
: undefined,
|
|
});
|
|
|
|
if (args.queueGeneration !== false) {
|
|
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
|
}
|
|
|
|
return args.runId;
|
|
} catch (error) {
|
|
const { errorSummary } = classifyPageSpeedFailure(error, apiKeyRaw);
|
|
await ctx.runMutation(internal.pageSpeed.finishPageSpeedAuditRun, {
|
|
runId: args.runId,
|
|
status: "failed",
|
|
errors: Math.max(1, failedStrategies),
|
|
errorSummary,
|
|
});
|
|
|
|
await ctx.runMutation(internal.runs.appendEventInternal, {
|
|
runId: args.runId,
|
|
level: "error",
|
|
message: "PageSpeed-Analyse fehlgeschlagen.",
|
|
details: [{ label: "Fehler", value: errorSummary, source: "pagespeed_action" }],
|
|
});
|
|
if (args.queueGeneration !== false) {
|
|
await queueAuditGenerationAfterPageSpeed(ctx, args.runId, started);
|
|
}
|
|
return null;
|
|
}
|
|
},
|
|
});
|
|
|
|
export const processPageSpeedAuditForWorkflow = internalAction({
|
|
args: {
|
|
runId: v.id("agentRuns"),
|
|
},
|
|
handler: async (ctx, args): Promise<Id<"agentRuns">> => {
|
|
const result = await ctx.runAction(
|
|
internal.pageSpeedAction.processPageSpeedAudit,
|
|
{
|
|
runId: args.runId,
|
|
queueGeneration: false,
|
|
},
|
|
);
|
|
const run = await ctx.runQuery(internal.runs.getAuditRunForWorkflowInternal, {
|
|
id: args.runId,
|
|
});
|
|
|
|
if (!result || run?.status === "failed" || run?.status === "canceled") {
|
|
throw new Error("PageSpeed-Analyse konnte nicht abgeschlossen werden.");
|
|
}
|
|
|
|
return args.runId;
|
|
},
|
|
});
|