The marketing site's "Run Live Audit" widget was entirely fake: it looked up a canned preset or a generic templated result for any domain typed in, after a scripted delay. This adds /api/public/quick-audit, which crawls the submitted URL with the same crawler + HTML analyzer the authenticated product uses, and reports one real finding (or an honest error if the site is unreachable/blocked) instead of ever fabricating one. Per-IP and global daily KV caps bound the cost of anonymous abuse. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
import type { CrawledPageResult } from "@/server/lib/audit/types";
|
||
|
||
export type QuickAuditPriority = "Critical Impact" | "High Priority" | "Good Health";
|
||
|
||
export interface QuickAuditFinding {
|
||
domain: string;
|
||
score: number;
|
||
action: string;
|
||
issue: string;
|
||
priority: QuickAuditPriority;
|
||
current: string;
|
||
suggested: string;
|
||
}
|
||
|
||
const TITLE_MAX_LENGTH = 60;
|
||
const DESCRIPTION_MAX_LENGTH = 160;
|
||
|
||
type Issue = Omit<QuickAuditFinding, "domain" | "score"> & { weight: number };
|
||
|
||
/**
|
||
* Picks the single most-impactful real issue on a crawled page, plus a
|
||
* composite score from every issue found. Used by the public marketing-site
|
||
* "quick audit" widget, which crawls exactly one real page (via
|
||
* {@link import("@/server/workflows/site-audit-workflow-helpers").crawlPage})
|
||
* — this never fabricates a finding for a page it hasn't actually fetched.
|
||
*/
|
||
export function selectQuickAuditFinding(
|
||
domain: string,
|
||
page: CrawledPageResult,
|
||
): QuickAuditFinding {
|
||
const title = page.title.trim();
|
||
const description = page.metaDescription.trim();
|
||
const issues: Issue[] = [];
|
||
|
||
if (!title) {
|
||
issues.push({
|
||
weight: 30,
|
||
action: "Add a title tag",
|
||
issue:
|
||
"This page has no <title> tag — search engines fall back to a generic or URL-based title in results.",
|
||
priority: "Critical Impact",
|
||
current: "<!-- no <title> tag found -->",
|
||
suggested: `<title>${domain} | Describe this page in 50-60 characters</title>`,
|
||
});
|
||
} else if (title.length > TITLE_MAX_LENGTH) {
|
||
issues.push({
|
||
weight: 15,
|
||
action: "Shorten the title tag",
|
||
issue: `Title tag is ${title.length} characters — Google truncates titles past ~${TITLE_MAX_LENGTH} characters in search results.`,
|
||
priority: "High Priority",
|
||
current: `<title>${title}</title>`,
|
||
suggested: `<title>${title.slice(0, TITLE_MAX_LENGTH - 1).trim()}…</title>`,
|
||
});
|
||
}
|
||
|
||
if (!description) {
|
||
issues.push({
|
||
weight: 20,
|
||
action: "Add a meta description",
|
||
issue:
|
||
"No meta description found — Google will auto-generate a snippet from page content instead.",
|
||
priority: "High Priority",
|
||
current: '<!-- no <meta name="description"> tag found -->',
|
||
suggested: `<meta name="description" content="Describe what ${domain} offers in one or two sentences, under ${DESCRIPTION_MAX_LENGTH} characters." />`,
|
||
});
|
||
} else if (description.length > DESCRIPTION_MAX_LENGTH) {
|
||
issues.push({
|
||
weight: 8,
|
||
action: "Shorten the meta description",
|
||
issue: `Meta description is ${description.length} characters — Google truncates snippets past ~${DESCRIPTION_MAX_LENGTH} characters.`,
|
||
priority: "High Priority",
|
||
current: `<meta name="description" content="${description}" />`,
|
||
suggested: `<meta name="description" content="${description.slice(0, DESCRIPTION_MAX_LENGTH - 1).trim()}…" />`,
|
||
});
|
||
}
|
||
|
||
if (page.h1Count === 0) {
|
||
issues.push({
|
||
weight: 18,
|
||
action: "Add an H1 heading",
|
||
issue:
|
||
"No <h1> tag found on this page — a single clear H1 helps both users and search engines understand the page topic.",
|
||
priority: "High Priority",
|
||
current: "<!-- no <h1> tag found -->",
|
||
suggested: "<h1>State the page's main topic here</h1>",
|
||
});
|
||
} else if (page.h1Count > 1) {
|
||
issues.push({
|
||
weight: 10,
|
||
action: "Use a single H1 per page",
|
||
issue: `${page.h1Count} <h1> tags found — multiple H1s dilute the page's topical signal.`,
|
||
priority: "High Priority",
|
||
current: `${page.h1Count}× <h1>…</h1>`,
|
||
suggested: "Keep exactly one <h1>; demote the rest to <h2>.",
|
||
});
|
||
}
|
||
|
||
if (!page.canonicalUrl) {
|
||
issues.push({
|
||
weight: 10,
|
||
action: "Add a canonical tag",
|
||
issue:
|
||
"No canonical URL found — without one, duplicate or parameterized versions of this page can compete with each other in search results.",
|
||
priority: "High Priority",
|
||
current: '<!-- no <link rel="canonical"> tag found -->',
|
||
suggested: `<link rel="canonical" href="${page.url}" />`,
|
||
});
|
||
}
|
||
|
||
if (page.imagesMissingAlt > 0) {
|
||
issues.push({
|
||
weight: 6,
|
||
action: "Add alt text to images",
|
||
issue: `${page.imagesMissingAlt} of ${page.imagesTotal} images on this page are missing alt text.`,
|
||
priority: "Good Health",
|
||
current: '<img src="...">',
|
||
suggested: '<img src="..." alt="Describe what the image shows">',
|
||
});
|
||
}
|
||
|
||
if (issues.length === 0) {
|
||
return {
|
||
domain,
|
||
score: 96,
|
||
action: "No critical issues found on this quick scan",
|
||
issue:
|
||
"Title, meta description, H1, and canonical tag all look healthy on this page.",
|
||
priority: "Good Health",
|
||
current: `<title>${title}</title>`,
|
||
suggested:
|
||
"Run a full crawl in CrawlerX to check Core Web Vitals, internal linking, and every other page on the site.",
|
||
};
|
||
}
|
||
|
||
issues.sort((a, b) => b.weight - a.weight);
|
||
const totalPenalty = issues.reduce((sum, issue) => sum + issue.weight, 0);
|
||
const score = Math.max(35, 100 - totalPenalty);
|
||
const { weight: _weight, ...top } = issues[0];
|
||
|
||
return { domain, score, ...top };
|
||
}
|