Ben Senescu 1c74fded7b Site audit P0 (1/3): issue engine, incremental persistence, block detection (#362)
* Site audit P0 (1/3): issue engine, incremental persistence, block detection

Server-side foundation of the P0 feature set from docs/site-audit-pm-research.md:

- Issue engine: shared registry of issue types (severity, explanation,
  how-to-fix). Per-page reporters run inside crawl steps; cross-page checks
  (duplicate titles/descriptions/content, broken internal links, redirect
  chains/loops, orphan pages) run at finalize as SQL over the persisted crawl.
- New audit_links + audit_issues tables, audit_pages columns (depth, content
  hash, header signals, fetch class, sitemap flag); audit tables moved to
  src/db/{,pg/}audit.schema.ts; migrations 0029 (D1) / 0006 (PG).
- Incremental persistence: pages/links/issues written inside each crawl-batch
  step with deterministic row ids + upserts (retry idempotent); slim step
  state; robots.txt checkpointed as step state; merged progress steps keep a
  10k-page crawl within the Workflows step budget.
- Crawler: manual redirect handling with inline follow of normalization-
  equivalent redirects, response header capture (X-Robots-Tag, Link
  rel=canonical), BFS depth, sitemap-last seeding, SSRF check on discovered
  links, honest 'we were blocked' classification (403/429/cf-mitigated/
  challenge).
- MCP: run_site_audit, get_audit_status, get_audit_issues, get_audit_pages;
  limitTier resolved via shared AuditService.resolveAuditLimitTier.
- Lighthouse strategies reduced to auto/none (legacy all/manual map on read).
- Self-healing: getStatus reconciles audits whose workflow instance errored/
  terminated without reaching mark-failed.

The Issues UI and the badseo.dev e2e fixture site stack on top of this PR.

Deploy notes: run db:migrate:prod (additive); terminate running audits before
deploying — the workflow step structure changed and in-flight instances cannot
replay under the new code (a finalize guard fails them loudly instead of
completing empty).

* Store only internal link edges in audit_links

Both consumers (broken-internal-link and orphan checks) filter on
isInternal; per-page external counts already live on audit_pages.
Dropping external rows cuts stored edges on outbound-heavy sites.
Column stays so P1 external-link checks can re-add rows without a
migration.

* Review fixes: failAudit CAS guard, dedupe hash helpers, cheaper checks

- failAudit only transitions running audits, so the getStatus reconciler
  can't flip a just-completed audit to failed when it races finalize
- collapse the duplicate SHA-256 helper into audit/ids.ts
- finalize integrity guard uses a limit-1 existence probe instead of
  fetching every page row
- get_audit_status MCP tool no longer reads the audit row twice when an
  explicit auditId is given
2026-07-07 22:08:14 -04:00

155 lines
4.8 KiB
TypeScript

/**
* Cross-page (multipage) issue checks.
*
* These run once after the crawl, against the rows the crawl wrote to D1:
* duplicates, broken internal links, redirect chains/loops, orphan pages.
* Pure set-queries over crawl data — no fetching, no DOM.
*/
import { and, eq, gte, lt, ne, notExists, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/sqlite-core";
import { db } from "@/db";
import { auditLinks, auditPages } from "@/db/schema";
import { normalizeUrl } from "@/server/lib/audit/url-utils";
import {
findDuplicates,
findRedirectChainsAndLoops,
type SlimPage,
} from "@/server/lib/audit/issues/multipage-checks";
import type { DetectedIssue } from "@/server/lib/audit/issues/page-reporters";
const BROKEN_LINK_ISSUE_CAP = 2_000;
export async function runMultipageChecks(input: {
auditId: string;
startUrl: string;
/** Orphan detection only makes sense when the crawl wasn't truncated. */
crawlCompleted: boolean;
}): Promise<DetectedIssue[]> {
const pages: SlimPage[] = await db
.select({
id: auditPages.id,
url: auditPages.url,
statusCode: auditPages.statusCode,
fetchClass: auditPages.fetchClass,
title: auditPages.title,
metaDescription: auditPages.metaDescription,
contentHash: auditPages.contentHash,
redirectUrl: auditPages.redirectUrl,
wordCount: auditPages.wordCount,
isIndexable: auditPages.isIndexable,
canonicalUrl: auditPages.canonicalUrl,
headerCanonicalUrl: auditPages.headerCanonicalUrl,
})
.from(auditPages)
.where(eq(auditPages.auditId, input.auditId));
const issues: DetectedIssue[] = [
...findDuplicates(pages),
...findRedirectChainsAndLoops(pages),
...(await findBrokenInternalLinks(input.auditId)),
];
if (input.crawlCompleted) {
// Page rows store normalized URLs; normalize the start URL the same way
// so the orphan exclusion matches.
const normalizedStart = normalizeUrl(input.startUrl) ?? input.startUrl;
issues.push(...(await findOrphanPages(input.auditId, normalizedStart)));
}
return issues;
}
async function findBrokenInternalLinks(
auditId: string,
): Promise<DetectedIssue[]> {
// Only flag targets we actually crawled and saw fail — never inferred from
// absence. Blocked targets (WAF challenges) are excluded: a 403 from bot
// protection is not evidence of a broken link.
const rows = await db
.select({
sourcePageId: auditLinks.sourcePageId,
sourceUrl: auditLinks.sourceUrl,
targetUrl: auditLinks.targetUrl,
targetStatus: auditPages.statusCode,
})
.from(auditLinks)
.innerJoin(
auditPages,
and(
eq(auditPages.auditId, auditLinks.auditId),
eq(auditPages.url, auditLinks.targetUrl),
),
)
.where(
and(
eq(auditLinks.auditId, auditId),
eq(auditLinks.isInternal, true),
gte(auditPages.statusCode, 400),
eq(auditPages.fetchClass, "ok"),
),
)
.limit(BROKEN_LINK_ISSUE_CAP);
return rows.map((row) => ({
issueType: "broken-internal-link" as const,
pageId: row.sourcePageId,
pageUrl: row.sourceUrl,
dedupeKey: row.targetUrl,
details: { targetUrl: row.targetUrl, targetStatus: row.targetStatus },
}));
}
async function findOrphanPages(
auditId: string,
startUrl: string,
): Promise<DetectedIssue[]> {
// A live 2xx page is an orphan when no OTHER crawled page links to it and
// nothing redirects to it. Only meaningful on a completed crawl: on a
// truncated one, "no observed inlinks" is true of nearly everything.
// Error/redirect rows aren't orphans — they already get their own issues.
const inlink = db
.select({ one: sql`1` })
.from(auditLinks)
.where(
and(
eq(auditLinks.auditId, auditId),
eq(auditLinks.isInternal, true),
eq(auditLinks.targetUrl, auditPages.url),
// Self-links (breadcrumbs, anchors) don't make a page reachable.
ne(auditLinks.sourcePageId, auditPages.id),
),
);
const redirectSourcePages = alias(auditPages, "redirect_sources");
const redirectSources = db
.select({ one: sql`1` })
.from(redirectSourcePages)
.where(
and(
eq(redirectSourcePages.auditId, auditId),
eq(redirectSourcePages.redirectUrl, auditPages.url),
),
);
const rows = await db
.select({ id: auditPages.id, url: auditPages.url })
.from(auditPages)
.where(
and(
eq(auditPages.auditId, auditId),
ne(auditPages.url, startUrl),
eq(auditPages.fetchClass, "ok"),
gte(auditPages.statusCode, 200),
lt(auditPages.statusCode, 300),
notExists(inlink),
notExists(redirectSources),
),
);
return rows.map((row) => ({
issueType: "orphan-page" as const,
pageId: row.id,
pageUrl: row.url,
}));
}