Integrate PageSpeed Insights audits

This commit is contained in:
2026-06-04 22:12:59 +02:00
parent 99d61ac736
commit f0a948aec9
19 changed files with 3755 additions and 12 deletions

314
convex/pageSpeed.ts Normal file
View File

@@ -0,0 +1,314 @@
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
const PAGE_SPEED_COUNTER_TEMPLATE = {
leadsFound: 1,
leadsCreated: 0,
auditsCreated: 1,
outreachPrepared: 0,
errors: 0,
};
type PageSpeedLead = Pick<
Doc<"leads">,
"_id" | "contactStatus"
> & {
websiteUrl: string;
};
const runStatus = v.union(
v.literal("pending"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
v.literal("canceled"),
);
const pageSpeedStrategy = v.union(v.literal("mobile"), v.literal("desktop"));
const pageSpeedResultStatus = v.union(
v.literal("succeeded"),
v.literal("failed"),
);
const pageSpeedErrorType = v.union(
v.literal("quota"),
v.literal("timeout"),
v.literal("unavailable"),
v.literal("invalid_url"),
v.literal("api_error"),
v.literal("unknown"),
);
export const queueLeadPageSpeedAudit = internalMutation({
args: {
leadId: v.id("leads"),
parentRunId: v.optional(v.id("agentRuns")),
},
returns: v.union(v.id("agentRuns"), v.null()),
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead || lead.priority === "blocked" || lead.priority === "defer") {
return null;
}
if (!lead.websiteUrl) {
return null;
}
const existingPending = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "pending").eq("leadId", args.leadId),
)
.take(1);
const existingRunning = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "running").eq("leadId", args.leadId),
)
.take(1);
if (existingPending.length > 0) {
return existingPending[0]._id;
}
if (existingRunning.length > 0) {
return existingRunning[0]._id;
}
const runId = await ctx.db.insert("agentRuns", {
type: "audit",
leadId: args.leadId,
status: "pending",
currentStep: "pagespeed_insights",
counters: PAGE_SPEED_COUNTER_TEMPLATE,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId,
level: "info",
message: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
details: [
{ label: "Lead", value: args.leadId },
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
],
createdAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.pageSpeedAction.processPageSpeedAudit,
{
runId,
},
);
return runId;
},
});
export const startPageSpeedAuditRun = internalMutation({
args: {
runId: v.id("agentRuns"),
},
returns: v.union(
v.object({
lead: v.object({
_id: v.id("leads"),
websiteUrl: v.string(),
contactStatus: v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
}),
auditId: v.optional(v.id("audits")),
}),
v.null(),
),
handler: async (ctx, args): Promise<
{ lead: PageSpeedLead; auditId?: Id<"audits"> } | null
> => {
const now = Date.now();
const run = await ctx.db.get(args.runId);
if (!run) {
return null;
}
if (run.type !== "audit") {
return null;
}
if (run.status !== "pending") {
return null;
}
if (!run.leadId) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "pagespeed_insights",
errorSummary: "Run hat keine Lead-ID.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead verknüpft.",
details: [{ label: "Lead-ID", value: "unbekannt" }],
createdAt: now,
});
return null;
}
const lead = await ctx.db.get(run.leadId);
if (!lead) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "pagespeed_insights",
errorSummary: "Lead wurde nicht gefunden.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.",
details: [{ label: "Lead-ID", value: run.leadId }],
createdAt: now,
});
return null;
}
if (!lead.websiteUrl) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "pagespeed_insights",
errorSummary: "Lead hat keine Website-URL.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.",
details: [{ label: "Lead-ID", value: lead._id }],
createdAt: now,
});
return null;
}
await ctx.db.patch(args.runId, {
status: "running",
currentStep: "pagespeed_insights",
startedAt: now,
updatedAt: now,
errorSummary: undefined,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "PageSpeed-Analyse gestartet.",
details: [{ label: "Lead-ID", value: lead._id }],
createdAt: now,
});
return {
lead: {
_id: lead._id,
websiteUrl: lead.websiteUrl,
contactStatus: lead.contactStatus,
},
...(run.auditId ? { auditId: run.auditId } : {}),
};
},
});
export const persistPageSpeedResult = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.id("agentRuns"),
strategy: pageSpeedStrategy,
status: pageSpeedResultStatus,
sourceUrl: v.string(),
finalUrl: v.optional(v.string()),
rawStorageId: v.optional(v.id("_storage")),
errorType: v.optional(pageSpeedErrorType),
errorSummary: v.optional(v.string()),
fetchedAt: v.number(),
normalized: v.optional(
v.object({
scores: v.optional(
v.object({
performance: v.optional(v.number()),
accessibility: v.optional(v.number()),
bestPractices: v.optional(v.number()),
seo: v.optional(v.number()),
}),
),
metrics: v.optional(
v.object({
firstContentfulPaintMs: v.optional(v.number()),
largestContentfulPaintMs: v.optional(v.number()),
cumulativeLayoutShift: v.optional(v.number()),
totalBlockingTimeMs: v.optional(v.number()),
speedIndexMs: v.optional(v.number()),
}),
),
opportunities: v.optional(v.array(v.string())),
implications: v.optional(v.array(v.string())),
}),
),
},
returns: v.id("pageSpeedResults"),
handler: async (ctx, args): Promise<Id<"pageSpeedResults">> => {
return await ctx.db.insert("pageSpeedResults", {
...args,
createdAt: Date.now(),
});
},
});
export const finishPageSpeedAuditRun = internalMutation({
args: {
runId: v.id("agentRuns"),
status: runStatus,
errorSummary: v.optional(v.string()),
errors: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
await ctx.db.patch(args.runId, {
status: args.status,
updatedAt: now,
finishedAt: now,
currentStep: "pagespeed_insights",
errorSummary: args.errorSummary,
counters: {
...PAGE_SPEED_COUNTER_TEMPLATE,
errors: args.errors ?? 0,
},
});
},
});