← back to Open Seo

badseo/scripts/run-audit.ts

420 lines

/**
 * End-to-end audit harness for badseo.dev.
 *
 * This drives the REAL OpenSEO audit engine (the same crawl + issue-detection
 * functions the production Worker uses) against a running badseo.dev, then
 * checks that every fixture triggers exactly the audit issues it declares.
 *
 * It reimplements only the crawl *frontier* loop — deliberately, so it can
 * crawl localhost (the production frontier's SSRF policy blocks private hosts).
 * Every actual detection call below is imported straight from ../src.
 *
 *   pnpm --filter badseo audit                 # against http://localhost:8787
 *   tsx scripts/run-audit.ts http://host:port  # against any origin
 */
import { crawlPage } from "../../src/server/workflows/site-audit-workflow-helpers";
import {
  discoverUrls,
  parseRobotsTxt,
} from "../../src/server/lib/audit/discovery";
import {
  normalizeUrl,
  isSameOrigin,
} from "../../src/server/lib/audit/url-utils";
import { runPageReporters } from "../../src/server/lib/audit/issues/page-reporters";
import {
  findDuplicates,
  findRedirectChainsAndLoops,
  type SlimPage,
} from "../../src/server/lib/audit/issues/multipage-checks";
import type { DetectedIssue } from "../../src/server/lib/audit/issues/page-reporters";
import type { CrawledPageResult } from "../../src/server/lib/audit/types";
import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";
import { allFixtures, fixturePaths } from "../src/fixtures/registry";
import { TRAILING_SLASH_CANONICAL } from "../src/fixtures/redirects";
import type { Fixture, IssueId } from "../src/fixtures/types";

const BASE = (process.argv[2] ?? "http://localhost:8787").replace(/\/$/, "");
const MAX_PAGES = 200;
const CONCURRENCY = 10;

interface CrawlLink {
  sourceId: string;
  sourceUrl: string;
  targetUrl: string;
}

async function warmup(): Promise<void> {
  // Prime the dev server so healthy pages don't read as slow on a cold start.
  const paths = new Set<string>(["/", "/privacy"]);
  for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p);
  await Promise.all(
    [...paths].map((p) =>
      fetch(`${BASE}${p}`, {
        redirect: "manual",
        signal: AbortSignal.timeout(8000),
      }).catch(() => {}),
    ),
  );
}

interface CrawlEntry {
  url: string;
  depth: number | null;
}

/** BFS crawl frontier: link-discovered URLs first, sitemap-only URLs last. */
async function crawl(origin: string): Promise<{
  pages: CrawledPageResult[];
  links: CrawlLink[];
  completed: boolean;
}> {
  const { urls: sitemapUrls, robotsText } = await discoverUrls(
    origin,
    MAX_PAGES,
  );
  const robots = parseRobotsTxt(origin, robotsText);
  const sitemapSet = new Set(
    sitemapUrls.map((u) => normalizeUrl(u)).filter((u): u is string => !!u),
  );

  const visited = new Set<string>();
  const queued = new Set<string>();
  const linkQueue: CrawlEntry[] = [];
  const sitemapQueue: CrawlEntry[] = [];

  const start = normalizeUrl(`${origin}/`) ?? `${origin}/`;
  linkQueue.push({ url: start, depth: 0 });
  queued.add(start);
  for (const u of sitemapSet) {
    if (u === start || queued.has(u)) continue;
    sitemapQueue.push({ url: u, depth: null });
    queued.add(u);
  }

  const pages: CrawledPageResult[] = [];
  const links: CrawlLink[] = [];

  const enqueue = (url: string, depth: number | null) => {
    const n = normalizeUrl(url);
    if (!n) return;
    if (!isSameOrigin(n, origin)) return;
    if (visited.has(n) || queued.has(n)) return;
    if (!robots.isAllowed(n)) return;
    linkQueue.push({ url: n, depth });
    queued.add(n);
  };

  while (
    (linkQueue.length > 0 || sitemapQueue.length > 0) &&
    pages.length < MAX_PAGES
  ) {
    const batch: CrawlEntry[] = [];
    while (
      (linkQueue.length > 0 || sitemapQueue.length > 0) &&
      batch.length < CONCURRENCY &&
      pages.length + batch.length < MAX_PAGES
    ) {
      const entry = (linkQueue.length > 0 ? linkQueue : sitemapQueue).shift()!;
      queued.delete(entry.url);
      if (visited.has(entry.url)) continue;
      visited.add(entry.url);
      batch.push(entry);
    }

    const crawled = await Promise.all(
      batch.map((e) => crawlPage(e.url, e.depth, sitemapSet.has(e.url))),
    );

    for (let i = 0; i < crawled.length; i++) {
      const page = crawled[i];
      const depth = batch[i].depth;
      pages.push(page);

      for (const link of page.links) {
        if (link.isInternal) {
          links.push({
            sourceId: page.id,
            sourceUrl: page.url,
            targetUrl: link.targetUrl,
          });
          enqueue(link.targetUrl, depth === null ? null : depth + 1);
        }
      }
      if (page.redirectUrl) enqueue(page.redirectUrl, depth);
    }
  }

  const completed = linkQueue.length === 0 && sitemapQueue.length === 0;
  return { pages, links, completed };
}

/** In-memory equivalents of the two D1-backed multipage checks. */
function findBrokenInternalLinks(
  pages: CrawledPageResult[],
  links: CrawlLink[],
): DetectedIssue[] {
  const byUrl = new Map(pages.map((p) => [p.url, p]));
  const issues: DetectedIssue[] = [];
  const seen = new Set<string>();
  for (const link of links) {
    const target = byUrl.get(link.targetUrl);
    if (!target) continue;
    if (target.fetchClass !== "ok" || target.statusCode < 400) continue;
    const key = `${link.sourceUrl}::${link.targetUrl}`;
    if (seen.has(key)) continue;
    seen.add(key);
    issues.push({
      issueType: "broken-internal-link",
      pageId: link.sourceId,
      pageUrl: link.sourceUrl,
      dedupeKey: link.targetUrl,
      details: { targetUrl: link.targetUrl, targetStatus: target.statusCode },
    });
  }
  return issues;
}

function findOrphanPages(
  pages: CrawledPageResult[],
  links: CrawlLink[],
  startUrl: string,
): DetectedIssue[] {
  const hasInlink = new Set<string>();
  for (const link of links) {
    const source = pages.find((p) => p.id === link.sourceId);
    // Self-links don't make a page reachable.
    if (source && source.url === link.targetUrl) continue;
    hasInlink.add(link.targetUrl);
  }
  const redirectTargets = new Set(
    pages.map((p) => p.redirectUrl).filter((u): u is string => !!u),
  );
  const issues: DetectedIssue[] = [];
  for (const page of pages) {
    if (page.fetchClass !== "ok") continue;
    if (page.statusCode < 200 || page.statusCode >= 300) continue;
    if (page.url === startUrl) continue;
    if (hasInlink.has(page.url) || redirectTargets.has(page.url)) continue;
    issues.push({
      issueType: "orphan-page",
      pageId: page.id,
      pageUrl: page.url,
    });
  }
  return issues;
}

function toSlim(page: CrawledPageResult): SlimPage {
  return {
    id: page.id,
    url: page.url,
    statusCode: page.statusCode,
    fetchClass: page.fetchClass,
    title: page.title || null,
    metaDescription: page.metaDescription || null,
    contentHash: page.contentHash,
    redirectUrl: page.redirectUrl,
    wordCount: page.wordCount,
    isIndexable: page.isIndexable,
    canonicalUrl: page.canonicalUrl,
    headerCanonicalUrl: page.headerCanonicalUrl,
  };
}

// ---- ANSI helpers -------------------------------------------------------
const c = {
  red: (s: string) => `\x1b[31m${s}\x1b[0m`,
  green: (s: string) => `\x1b[32m${s}\x1b[0m`,
  yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
  dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
  bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
};

const INFO_ISSUES = new Set<IssueId>(
  (Object.keys(AUDIT_ISSUE_TYPES) as IssueId[]).filter(
    (id) => AUDIT_ISSUE_TYPES[id].severity === "info",
  ),
);

async function main() {
  console.log(c.bold(`\n🧪 badseo.dev audit harness → ${BASE}\n`));

  await warmup();
  const origin = new URL(BASE).origin;
  const startUrl = normalizeUrl(`${origin}/`) ?? `${origin}/`;
  const { pages, links, completed } = await crawl(origin);

  if (process.env.DEBUG_DEPTH) {
    for (const p of pages) {
      if (p.url.includes("/structure/deep/")) {
        console.log(`  depth ${p.crawlDepth}  ${p.url}`);
      }
    }
  }

  // Assign deterministic-ish ids already handled by crawlPage; run detection.
  const detected: DetectedIssue[] = [];
  for (const page of pages) detected.push(...runPageReporters(page));
  const slim = pages.map(toSlim);
  detected.push(...findDuplicates(slim));
  detected.push(...findRedirectChainsAndLoops(slim));
  detected.push(...findBrokenInternalLinks(pages, links));
  if (completed) detected.push(...findOrphanPages(pages, links, startUrl));

  const byUrl = new Map<string, Set<IssueId>>();
  for (const issue of detected) {
    const set = byUrl.get(issue.pageUrl) ?? new Set<IssueId>();
    set.add(issue.issueType);
    byUrl.set(issue.pageUrl, set);
  }
  const crawledUrls = new Set(pages.map((p) => p.url));

  console.log(
    c.dim(
      `crawled ${pages.length} pages, ${links.length} internal links, ${detected.length} issues, crawl ${
        completed ? "completed" : "TRUNCATED"
      }\n`,
    ),
  );

  interface Row {
    name: string;
    url: string;
    ok: boolean;
    detail: string;
  }
  const rows: Row[] = [];
  let failures = 0;

  const check = (
    name: string,
    path: string,
    expected: IssueId[],
    support: boolean,
  ) => {
    const url = normalizeUrl(`${origin}${path}`) ?? `${origin}${path}`;
    const got = byUrl.get(url) ?? new Set<IssueId>();
    if (!crawledUrls.has(url)) {
      failures++;
      rows.push({ name, url, ok: false, detail: c.red("NOT CRAWLED") });
      return;
    }
    const expectedSet = new Set(expected);
    if (support) {
      // Support pages may carry info-level noise (e.g. a deep waypoint that is
      // itself deep) but must not have critical/warning issues.
      const bad = [...got].filter((id) => !INFO_ISSUES.has(id));
      const ok = bad.length === 0;
      if (!ok) failures++;
      rows.push({
        name,
        url,
        ok,
        detail: ok
          ? c.dim("clean (support)")
          : c.red(`unexpected: ${bad.join(", ")}`),
      });
      return;
    }
    const missing = [...expectedSet].filter((id) => !got.has(id));
    const extra = [...got].filter((id) => !expectedSet.has(id));
    const ok = missing.length === 0 && extra.length === 0;
    if (!ok) failures++;
    const parts: string[] = [];
    if (missing.length) parts.push(c.red(`missing: ${missing.join(", ")}`));
    if (extra.length) parts.push(c.yellow(`extra: ${extra.join(", ")}`));
    rows.push({
      name,
      url,
      ok,
      detail: ok ? c.green(expected.join(", ") || "clean") : parts.join("  "),
    });
  };

  // Non-fixture content pages must be clean.
  check("Homepage", "/", [], false);
  check("Privacy policy", "/privacy", [], false);

  const byCategory = new Map<string, Fixture[]>();
  for (const f of allFixtures) {
    const list = byCategory.get(f.category) ?? [];
    list.push(f);
    byCategory.set(f.category, list);
  }

  for (const f of allFixtures) {
    check(f.name, f.path, f.expectedIssues, f.support === true);
  }

  // Explicit regression guard for the trailing-slash redirect cycle
  // (every-app/open-seo#61): the canonical page must resolve to a 200 with no
  // redirect loop and no error. Fix-agnostic on purpose — the 200 lands on the
  // slash form under the root-cause fix (slashes preserved) or on the non-slash
  // form under older slash-stripping code that inline-follows. A crawler that
  // still strips and doesn't follow would 508 or record a self-redirect loop.
  {
    const base = `${origin}${TRAILING_SLASH_CANONICAL}`;
    const urls = [base, `${base}/`];
    const related = pages.filter((p) => urls.includes(p.url));
    const has200 = related.some(
      (p) => p.fetchClass === "ok" && p.statusCode >= 200 && p.statusCode < 300,
    );
    const looped = related.some((p) =>
      (byUrl.get(p.url) ?? new Set<IssueId>()).has("redirect-loop"),
    );
    const errored = related.some(
      (p) => p.fetchClass === "error" || p.statusCode >= 500,
    );
    const ok = has200 && !looped && !errored;
    if (!ok) failures++;
    rows.push({
      name: "Trailing-slash cycle → 200, no loop",
      url: base,
      ok,
      detail: ok
        ? c.green("canonical crawled as 200, no redirect loop")
        : c.red(
            related.length === 0
              ? "NOT CRAWLED (looped or dropped)"
              : `200=${has200} loop=${looped} error=${errored}`,
          ),
    });
  }

  // ---- report ----
  const pad = Math.max(...rows.map((r) => r.name.length));
  for (const r of rows) {
    const icon = r.ok ? c.green("✓") : c.red("✗");
    console.log(`${icon} ${r.name.padEnd(pad)}  ${r.detail}`);
  }

  const total = rows.length;
  console.log(
    "\n" +
      (failures === 0
        ? c.green(c.bold(`ALL ${total} CHECKS PASSED`))
        : c.red(c.bold(`${failures}/${total} CHECKS FAILED`))),
  );

  // Coverage: every audit issue type should be exercised by at least one page.
  const exercised = new Set(allFixtures.flatMap((f) => f.expectedIssues));
  const allTypes = Object.keys(AUDIT_ISSUE_TYPES) as IssueId[];
  const uncovered = allTypes.filter((id) => !exercised.has(id));
  console.log(
    c.dim(
      `\nissue-type coverage: ${allTypes.length - uncovered.length}/${allTypes.length}` +
        (uncovered.length
          ? `  (missing: ${uncovered.join(", ")})`
          : "  ✓ all covered"),
    ),
  );

  process.exit(failures === 0 ? 0 : 1);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});