From bd59c976e9aea19b4bf4fa02c755d0c275f58f1c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 25 Jul 2026 18:56:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(slop):=20free=20Slop=20Score=20engine=20?= =?UTF-8?q?=E2=80=94=2050-page=20content/code/design=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a free, deterministic scan engine that sweeps up to 50 same-origin pages and scores how careless a site looks: 0 is pristine, 100 is maximum slop. Output is the headline score, per-page fix lists, and systemic rollups for defects that live in a shared template. Discovery is sitemap.xml first (following sitemap indexes), then breadth-first from the entry page, bounded by a 3-minute deadline so it stays inside the worker's stuck-audit cutoff. A few same-origin stylesheets are fetched so the design checks can see palette, typography, and !important sprawl, which is invisible from HTML alone. Analyzers (lib/audit/checks/slop.ts) cover three dimensions: content — filler-phrase density, no first-party evidence, thin pages, near-duplicate bodies (5-word shingle Jaccard), boilerplate intros, placeholders, stale copyright, misspellings code — leaked template variables, dev/staging hosts, console.* and TODO leftovers, duplicate/missing metadata, dead links, deprecated tags design — missing viewport, unsized images, placeholder alt text, stock-only imagery, inline-style density, style sprawl Two design rules are load-bearing: It reports observable defects, never "this was written by AI". An AI-probability score is unfalsifiable, the classifiers are unreliable (they systematically flag non-native English writers), and it would accuse paying customers. Every finding is something an owner can verify in ten seconds and fix. Ambiguous markers only count in unambiguous positions. Dogfooding on our own blog and on Hacker News turned up four false positives, each now covered by a regression test: "a feature is coming soon" and "your brand name in an AI answer" in running prose, "[product]"/"[your site]" as editorial shorthand, and .netlify.app/.vercel.app appearing as scan-result text or as an outbound link rather than a leaked URL. Hence standalone-only phrase matching and attribute-only host scanning, with preview hosts counted only in resource positions. Palette sprawl also ignores custom-property definitions, so having design tokens is no longer penalised. Wiring: engine registered at cost 0 (lib/credits.ts), worker dispatch, engines panel, report view with a SlopMeter hero stat, and an audits_engine_check migration — without it every 'slop' insert would violate the constraint, the same trap the fugu and zai migrations document. Anonymous visitors can now pick between the AEO audit and the Slop Score on the hero form; both are free, self-hosted, and page-budgeted, and the per-IP anonymous limit still applies. Runs no LLM, so it is immune to the shared-provider-quota outages that stall Autoblog. Verified with 38 unit tests plus live sweeps of crawlproof.com (24, Clean), ugig.net (29) and news.ycombinator.com (58, Sloppy). Found a real duplicate-meta-description bug on three of our pages and unsized images across 38. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 +- app/actions/runAudit.ts | 15 +- components/charts/engine-trend.tsx | 1 + components/engines-panel.tsx | 2 +- components/hero-audit-form.tsx | 51 +- components/report/report-view.tsx | 7 +- components/report/slop-meter.tsx | 120 ++ lib/audit/checks/slop.ts | 1174 +++++++++++++++++ lib/audit/slop-engine.ts | 337 +++++ lib/credits.ts | 9 + .../migrations/20260725120000_engine_slop.sql | 9 + tests/slop.test.ts | 434 ++++++ worker/index.ts | 8 + 13 files changed, 2172 insertions(+), 8 deletions(-) create mode 100644 components/report/slop-meter.tsx create mode 100644 lib/audit/checks/slop.ts create mode 100644 lib/audit/slop-engine.ts create mode 100644 supabase/migrations/20260725120000_engine_slop.sql create mode 100644 tests/slop.test.ts diff --git a/README.md b/README.md index 25f078fe..97dbd084 100644 --- a/README.md +++ b/README.md @@ -131,12 +131,23 @@ Credit purchases are created through `/api/credits/create-invoice`; successful w ### Free audit -The homepage form queues a free rule-based audit. Email is optional: users get the on-page report immediately via `/r/`, and receive a PDF only when they provide an email. Anonymous free scans are unlisted by default; `/recent` and sitemap deep links include only scans where the submitter explicitly opted into public listing. Common tracking parameters such as `utm_*`, `fbclid`, and `gclid` are stripped before new URLs are saved. +The homepage form queues a free scan — either the rule-based **AEO audit** or the **Slop Score** (see below). Email is optional: users get the on-page report immediately via `/r/`, and receive a PDF only when they provide an email. Anonymous free scans are unlisted by default; `/recent` and sitemap deep links include only scans where the submitter explicitly opted into public listing. Common tracking parameters such as `utm_*`, `fbclid`, and `gclid` are stripped before new URLs are saved. ### Project scan Signed-in users can save sites as projects, choose one or more engines, and run scans from the project page. A multi-engine scan creates one `audits` row per engine and ties them together with `scan_run_id` for side-by-side reports, consolidated Markdown/PDF, and project score history. +### Slop Score + +`lib/audit/slop-engine.ts` sweeps up to 50 same-origin pages (sitemap.xml first, then breadth-first from the entry page) plus a handful of stylesheets, and scores how careless the site looks: **0 is pristine, 100 is maximum slop**. Analyzers live in `lib/audit/checks/slop.ts` across three dimensions — **content** (filler phrasing, no first-party evidence, thin/near-duplicate/boilerplate pages, placeholders, stale copyright, high-confidence misspellings), **code** (leaked template variables, dev/staging hosts, `console.*` and TODO leftovers, duplicate metadata, dead links, deprecated tags) and **design** (missing viewport, unsized images, placeholder alt text, stock-only imagery, inline-style density, palette/typography/`!important` sprawl). Output is the headline score plus a per-page fix list and systemic rollups for defects that live in a shared template. + +Two design rules matter when extending it: + +- **It reports observable defects, never "this was written by AI."** An AI-probability score is unfalsifiable, the classifiers are unreliable, and it would accuse paying customers. Every finding must be something the owner can verify in ten seconds and fix. +- **Ambiguous markers only count in unambiguous positions.** Dogfooding on our own blog showed `coming soon`, `your brand name`, `[product]` and `[your site]` all appear in legitimate prose, and a `.netlify.app` hostname appears as scan-result *text* on `/recent` — so standalone-only matching and attribute-only host scanning are load-bearing, not stylistic. `tests/slop.test.ts` guards each case. + +It is free (`cost: 0`), runs no LLM, and is therefore immune to the shared-provider-quota outages that stall Autoblog. + ### Credits and engines Rule-based scans cost 0 credits. Paid AI-model scans cost 1 credit per engine. Credit packs are defined in `lib/credits.ts`; failures and user-aborted paid scans refund credits. diff --git a/app/actions/runAudit.ts b/app/actions/runAudit.ts index b9161b48..35c86cca 100644 --- a/app/actions/runAudit.ts +++ b/app/actions/runAudit.ts @@ -47,10 +47,21 @@ async function notifyWorker(auditId: string, pdfEmail?: string) { } } -const ALL_ENGINES: Engine[] = ["rule", "spec", "dns", "links", "vu1nz", "claude", "openai", "gemini", "qwen", "kimi", "deepseek", "zai", "perplexity", "fugu"]; +const ALL_ENGINES: Engine[] = ["rule", "spec", "dns", "links", "slop", "vu1nz", "claude", "openai", "gemini", "qwen", "kimi", "deepseek", "zai", "perplexity", "fugu"]; + +// Free engines an anonymous visitor may pick from the hero form. Both are +// deterministic, self-hosted, and page-budgeted, so opening them up costs us +// nothing per run and can't be turned into an LLM-spend or partner-API faucet. +// The per-IP anonymous daily limit still applies to both. +const ANON_ENGINES: Engine[] = ["rule", "slop"]; function normalizeEngines(input: unknown, signedIn: boolean): Engine[] { - if (!signedIn) return ["rule"]; + if (!signedIn) { + const picked = Array.isArray(input) + ? input.find((e): e is Engine => typeof e === "string" && (ANON_ENGINES as string[]).includes(e)) + : undefined; + return [picked ?? "rule"]; + } if (!Array.isArray(input) || input.length === 0) return DEFAULT_PROJECT_ENGINES; const cleaned = dedupeEngines( input.filter((e): e is Engine => diff --git a/components/charts/engine-trend.tsx b/components/charts/engine-trend.tsx index 479b49a9..67aa2048 100644 --- a/components/charts/engine-trend.tsx +++ b/components/charts/engine-trend.tsx @@ -22,6 +22,7 @@ export type EngineTrendPoint = { const ENGINE_COLORS: Record = { rule: "#9aa3b2", + slop: "#d946ef", spec: "#64748b", dns: "#84cc16", links: "#14b8a6", diff --git a/components/engines-panel.tsx b/components/engines-panel.tsx index 63493705..93c50e20 100644 --- a/components/engines-panel.tsx +++ b/components/engines-panel.tsx @@ -6,7 +6,7 @@ import { DEFAULT_PROJECT_ENGINES, ENGINES, selectionCost, type Engine } from "@/ import { runScanForProject } from "@/app/actions/runAudit"; import { updateProjectEngines } from "@/app/actions/projects"; -const ENGINE_ORDER: Engine[] = ["rule", "spec", "dns", "links", "vu1nz", "claude", "openai", "gemini", "perplexity", "qwen", "kimi", "deepseek", "zai", "fugu"]; +const ENGINE_ORDER: Engine[] = ["rule", "slop", "spec", "dns", "links", "vu1nz", "claude", "openai", "gemini", "perplexity", "qwen", "kimi", "deepseek", "zai", "fugu"]; export function EnginesPanel({ projectId, diff --git a/components/hero-audit-form.tsx b/components/hero-audit-form.tsx index 215dc0b2..5ac86d0d 100644 --- a/components/hero-audit-form.tsx +++ b/components/hero-audit-form.tsx @@ -17,10 +17,28 @@ declare global { } } -export function HeroAuditForm() { +type HeroScan = "rule" | "slop"; + +// Only the two free, self-hosted engines are offered anonymously — kept in +// sync with ANON_ENGINES in app/actions/runAudit.ts. +const SCAN_OPTIONS: Array<{ id: HeroScan; label: string; blurb: string }> = [ + { + id: "rule", + label: "AEO audit", + blurb: "How AI crawlers and answer engines see your site, with fixes.", + }, + { + id: "slop", + label: "Slop Score", + blurb: "Sweeps up to 50 pages for careless content, code, and design.", + }, +]; + +export function HeroAuditForm({ defaultScan = "rule" }: { defaultScan?: HeroScan }) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [url, setUrl] = useState(""); + const [scan, setScan] = useState(defaultScan); const [listPublic, setListPublic] = useState(false); const [error, setError] = useState(null); const [urlError, setUrlError] = useState(null); @@ -41,7 +59,7 @@ export function HeroAuditForm() { // URL-first: the report is generated and shown on-page for free. // Email / phone / monthly-sales are collected AFTER the report // (on /r/) only if the user wants the PDF emailed. - const res = await startAuditFromForm({ url, listPublic }); + const res = await startAuditFromForm({ url, listPublic, engines: [scan] }); if (!res.ok) { setError(res.error ?? "Could not start audit."); // Slugify the error message so we don't blow past Datafa.st's @@ -56,6 +74,7 @@ export function HeroAuditForm() { } window.datafast?.("audit_submitted", { listed_public: listPublic ? "yes" : "no", + scan, }); router.push(`/r/${res.token}`); }); @@ -63,6 +82,32 @@ export function HeroAuditForm() { return (
+
+ + Scan type + +
+ {SCAN_OPTIONS.map((opt) => ( + + ))} +
+
- {pending ? "Starting…" : "Run free audit"} + {pending ? "Starting…" : scan === "slop" ? "Get free Slop Score" : "Run free audit"}
diff --git a/components/report/report-view.tsx b/components/report/report-view.tsx index 97769304..eee0a511 100644 --- a/components/report/report-view.tsx +++ b/components/report/report-view.tsx @@ -1,6 +1,8 @@ import { SECTIONS } from "@/lib/audit/prompt"; import { SectionFindings, type FixRun } from "./section"; import { DataFoundTable } from "./data-found"; +import { SlopMeter } from "./slop-meter"; +import { SLOP_SECTION } from "@/lib/audit/checks/slop"; import type { Finding } from "@/lib/audit/types"; import { ENGINES, type Engine } from "@/lib/credits"; @@ -98,6 +100,7 @@ export function ReportView({ } return ( + {s === SLOP_SECTION && } + {s === SLOP_SECTION && } f.section).filter(Boolean)), ); - const compactEngine = engine === "dns" || engine === "links" || engine === "spec"; + const compactEngine = + engine === "dns" || engine === "links" || engine === "spec" || engine === "slop"; if (compactEngine) { return findingSections.length > 0 ? findingSections : [...SECTIONS]; } diff --git a/components/report/slop-meter.tsx b/components/report/slop-meter.tsx new file mode 100644 index 00000000..590b3cb3 --- /dev/null +++ b/components/report/slop-meter.tsx @@ -0,0 +1,120 @@ +import type { Finding } from "@/lib/audit/types"; + +// The shareable headline of a Slop Score scan. +// +// Form choice: the score is a single number, so it's a hero stat — not a chart. +// The per-dimension breakdown is three magnitudes of the SAME measure (slop +// points), so it gets one neutral hue with identity carried by the text labels; +// the status tokens stay reserved for the score's own state, paired with the +// grade word so state is never conveyed by color alone. + +type SlopEvidence = { + score?: number; + grade?: string; + byDimension?: { content?: number; code?: number; design?: number }; + totals?: { pages?: number; issues?: number; words?: number }; +}; + +const DIMENSIONS = [ + { key: "content", label: "Content", hint: "filler, thin pages, duplicates, stale dates" }, + { key: "code", label: "Code", hint: "leaked template vars, dev URLs, dead links" }, + { key: "design", label: "Design", hint: "viewport, alt text, layout shift, style drift" }, +] as const; + +export function SlopMeter({ findings }: { findings: Finding[] }) { + const headline = findings.find((f) => f.check_key === "slop.score"); + const ev = (headline?.evidence ?? {}) as SlopEvidence; + const score = typeof ev.score === "number" ? ev.score : null; + if (score === null) return null; + + const grade = ev.grade ?? ""; + const dims = ev.byDimension ?? {}; + const totals = ev.totals ?? {}; + // Lower is better here, which inverts the usual AEO dial. + const tone = + score <= 25 ? "var(--color-pass)" : score <= 50 ? "var(--color-warn)" : "var(--color-fail)"; + const max = Math.max(1, ...DIMENSIONS.map((d) => dims[d.key] ?? 0)); + + return ( +
+
+
+

Slop Score

+

+ + {score} + + / 100 +

+

+ {grade} +

+
+ +
+ {/* Track runs low→high slop so the filled portion reads as "how much + slop", matching the number above it. */} +
+
+
+
+ 0 — pristine + 100 — maximum slop +
+

+ {[ + typeof totals.pages === "number" + ? `${totals.pages} page${totals.pages === 1 ? "" : "s"} swept` + : null, + typeof totals.words === "number" ? `${totals.words.toLocaleString()} words` : null, + typeof totals.issues === "number" + ? `${totals.issues} issue${totals.issues === 1 ? "" : "s"}` + : null, + ] + .filter(Boolean) + .join(" · ")} +

+
+
+ +
+

+ Slop points by dimension +

+ {DIMENSIONS.map((d) => { + const v = dims[d.key] ?? 0; + return ( +
+ {d.label} + + + + {v} + {d.hint} +
+ ); + })} +
+ +

+ This measures observable defects — placeholder copy, near-duplicate pages, + leaked template variables, missing first-party evidence, stale dates, design drift. It does + not estimate whether anything was written by AI. +

+
+ ); +} diff --git a/lib/audit/checks/slop.ts b/lib/audit/checks/slop.ts new file mode 100644 index 00000000..c3c4d601 --- /dev/null +++ b/lib/audit/checks/slop.ts @@ -0,0 +1,1174 @@ +// Slop Score — deterministic "does this site look careless?" analyzer. +// +// Design rule for this whole module: we report OBSERVABLE DEFECTS, never +// "this was written by AI". An AI-probability score is unfalsifiable, the +// classifiers are unreliable (they systematically flag non-native English +// writers), and we'd be accusing paying customers. Everything below is +// something the owner can open in a browser, verify in ten seconds, and fix. +// +// Three dimensions, each scanned across every crawled page: +// content — filler phrasing, no first-party evidence, thin/duplicate pages, +// placeholders, stale dates, high-confidence misspellings +// code — leaked dev artifacts, unrendered template vars, duplicate meta, +// empty links, deprecated tags, commented-out blocks +// design — no viewport, unsized images, placeholder alt text, inline-style +// density, palette/typography drift across stylesheets +// +// No LLM is involved, so this runs free and can't be taken down by a provider +// quota outage. + +import * as cheerio from "cheerio"; +import type { Finding } from "../types"; + +export type SlopDimension = "content" | "code" | "design"; + +export type SlopIssue = { + key: string; // stable id, e.g. "content.filler" + dimension: SlopDimension; + label: string; // human summary of what's wrong on this page + fix: string; // what to do about it + weight: number; // slop points contributed (per page) + count?: number; // how many instances on this page + samples?: string[]; // verbatim evidence the owner can grep for +}; + +export type SlopPage = { + url: string; + status: number; + html: string; + /** Visible text, already whitespace-collapsed. */ + text: string; + words: number; + title: string; + description: string; + h1: string; +}; + +export type SlopStylesheet = { url: string; css: string }; + +export type PageSlop = { + url: string; + words: number; + issues: SlopIssue[]; + points: number; +}; + +export type SlopReport = { + score: number; // 0 = pristine, 100 = maximum slop + grade: SlopGrade; + pages: PageSlop[]; + /** Cross-page issues (duplicate meta, near-duplicate bodies, palette drift). */ + siteIssues: SlopIssue[]; + byDimension: Record; + totals: { pages: number; issues: number; words: number }; +}; + +export type SlopGrade = "Pristine" | "Clean" | "Some slop" | "Sloppy" | "Slop factory"; + +// --------------------------------------------------------------------------- +// Signal dictionaries +// --------------------------------------------------------------------------- + +// Filler phrasing. These are not "AI words" — they're padding that carries no +// information whichever species typed it. Kept to phrases (not single words) +// so ordinary prose doesn't trip it. +const FILLER_PHRASES = [ + "in today's fast-paced world", + "in today's digital age", + "in today's ever-changing", + "in the ever-evolving", + "ever-evolving landscape", + "digital landscape", + "it's worth noting that", + "it is worth noting that", + "it's important to note that", + "it is important to note that", + "delve into", + "delving into", + "navigate the complexities", + "navigating the complexities", + "unlock the power", + "unlock the potential", + "harness the power", + "leverage the power", + "revolutionize the way", + "game changer", + "game-changer", + "at the end of the day", + "when it comes to", + "look no further", + "in conclusion", + "a testament to", + "rich tapestry", + "vibrant tapestry", + "seamlessly integrate", + "seamless integration", + "robust solution", + "cutting-edge solution", + "state-of-the-art solution", + "elevate your", + "take your business to the next level", + "in this article, we'll explore", + "in this article, we will explore", + "let's dive in", + "let's dive into", + "the world of", + "plays a crucial role", + "plays a vital role", + "paradigm shift", + "synergy between", + "best-in-class", + "one-stop shop", + "meet and exceed", + "wide range of solutions", + "tailored to your needs", + "whether you're a", +]; + +// Unfinished-content markers, split by how safe they are to match mid-sentence. +// +// ANYWHERE patterns have no legitimate reading in running prose. STANDALONE +// patterns do — an article about content freshness will say "a feature is +// coming soon", and an AEO guide will say "your brand name in an AI answer". +// Matching those as substrings produces exactly the kind of false accusation +// this module exists to avoid, so they only count when they are the ENTIRE +// text of an element (`

Coming soon

`), never when embedded in a +// sentence. This distinction was added after a dogfood run flagged two +// legitimate blog paragraphs on crawlproof.com itself. +const PLACEHOLDER_ANYWHERE: Array<{ re: RegExp; label: string }> = [ + { re: /lorem ipsum/i, label: "Lorem ipsum filler text" }, + { re: /\bdolor sit amet\b/i, label: "Lorem ipsum filler text" }, + { re: /\binsert (?:your |the )?(?:text|name|content|logo|image) here\b/i, label: "Unreplaced template copy" }, + { re: /\byour (?:company|business|brand|product) name here\b/i, label: "Unreplaced template copy" }, + // Only "[insert …]". Both `[product]` and `[your site]` turned out to be + // ordinary editorial shorthand in example copy on our own blog ("Does + // [product] support SSO?", "sign me up at [your site]"), so the bracket rule + // is narrowed to the one form nobody writes on purpose. + { re: /\[insert\b[^\]]{0,30}\]/i, label: "Unreplaced [bracketed] placeholder" }, + { re: /\bexample@example\.(?:com|org)\b/i, label: "Example email address" }, + { re: /\b(?:test|foo|asdf)@(?:test|example|foo)\.com\b/i, label: "Test email address" }, + { re: /\b(?:555-?)?555-?01\d{2}\b/, label: "Fake 555 phone number" }, + { re: /\bcreate next app\b/i, label: "Unchanged framework boilerplate" }, + { re: /\bvite \+ (?:react|vue|svelte)\b/i, label: "Unchanged framework boilerplate" }, + { re: /\bwelcome to (?:wordpress|nginx|apache)\b/i, label: "Unchanged server/CMS boilerplate" }, +]; + +const PLACEHOLDER_STANDALONE: Array<{ re: RegExp; label: string }> = [ + { re: /^coming soon[.!…]*$/i, label: '"Coming soon" placeholder' }, + { re: /^under construction[.!…]*$/i, label: '"Under construction" placeholder' }, + { re: /^(?:tbd|to be determined|to be announced|tba)[.!…]*$/i, label: '"TBD" placeholder' }, + { re: /^placeholder(?: text| content| image)?[.!…]*$/i, label: "Explicit placeholder content" }, + { re: /^your (?:company|business|brand|product) name[.!…]*$/i, label: "Unreplaced template copy" }, + { re: /^hello,? world[.!…]*$/i, label: '"Hello world" placeholder' }, + // "Title"/"Description" alone are ordinary table headers and definition-list + // terms, so only the unambiguous scaffold labels are listed here. + { re: /^(?:content goes here|body text goes here|your text here|edit this text)[.!…]*$/i, label: "Unreplaced template label" }, +]; + +// Short element texts, used for standalone-placeholder matching. A real +// placeholder occupies a whole heading, cell, or paragraph — never half a +// sentence — so we only consider elements whose entire text is short. +function standaloneTexts($: cheerio.CheerioAPI): string[] { + const out: string[] = []; + $( + "title, h1, h2, h3, h4, h5, h6, p, li, td, th, dd, dt, figcaption, button, a, span, strong, em, blockquote, div", + ).each((_, el) => { + const t = $(el).text().replace(/\s+/g, " ").trim(); + if (t && t.length <= 48) out.push(t); + }); + return out; +} + +// Template variables and JS accidents that leaked into rendered text. These +// are the single most embarrassing defect class and trivially fixable. +const LEAKED_VALUE_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: /\{\{\s*[\w.$[\]'"|\s-]+\s*\}\}/g, label: "Unrendered {{template}} variable" }, + { re: /\$\{\s*[\w.$[\]'"?.\s-]+\s*\}/g, label: "Unrendered ${template} literal" }, + { re: /\[object Object\]/g, label: '"[object Object]" in visible text' }, + { re: /\bundefined\b(?=\s|$|[.,!?)])/g, label: '"undefined" in visible text' }, + { re: /\bNaN\b(?=\s|$|[.,!?)])/g, label: '"NaN" in visible text' }, + { re: /\bnull\b(?=\s|$|[.,!?)])/g, label: '"null" in visible text' }, + { re: /%[sd]\b/g, label: "Unformatted %s / %d printf token" }, + { re: /\bInvalid Date\b/g, label: '"Invalid Date" in visible text' }, +]; + +// Only unambiguous misspellings with no valid alternate reading, so we never +// flag a customer's product name. No dictionary, no brand-name allowlist to +// maintain — that combination is what makes site-wide spellcheck unshippable. +const MISSPELLINGS: Record = { + recieve: "receive", + recieved: "received", + seperate: "separate", + seperately: "separately", + occured: "occurred", + occurence: "occurrence", + definately: "definitely", + accomodate: "accommodate", + accomodates: "accommodates", + buisness: "business", + sucessful: "successful", + sucessfully: "successfully", + enviroment: "environment", + neccessary: "necessary", + occassion: "occasion", + publically: "publicly", + existance: "existence", + maintainance: "maintenance", + priviledge: "privilege", + recomend: "recommend", + recomended: "recommended", + independant: "independent", + arguement: "argument", + begining: "beginning", + beleive: "believe", + calender: "calendar", + collegue: "colleague", + comittee: "committee", + concious: "conscious", + embarass: "embarrass", + goverment: "government", + garantee: "guarantee", + garanteed: "guaranteed", + harrass: "harass", + immediatly: "immediately", + knowlege: "knowledge", + liason: "liaison", + managment: "management", + millenium: "millennium", + noticable: "noticeable", + occuring: "occurring", + paticular: "particular", + perseverence: "perseverance", + possesion: "possession", + refered: "referred", + relevent: "relevant", + reccommend: "recommend", + rythm: "rhythm", + supercede: "supersede", + tommorow: "tomorrow", + untill: "until", + wich: "which", + writting: "writing", +}; + +// Hosts that never legitimately appear in a production page — not even as an +// outbound link. +const HARD_DEV_HOST_RE = + /\b(?:localhost(?::\d+)?|127\.0\.0\.1(?::\d+)?|0\.0\.0\.0(?::\d+)?|staging\.[\w.-]+|dev\.local|[\w-]+\.ngrok(?:-free)?\.(?:io|app|dev))\b/i; + +// Preview/PaaS hosts. These DO appear legitimately as outbound links (a link +// aggregator linking someone's Vercel demo, or our own /recent page listing +// "AEO audit for foo.netlify.app"), so they only count when the page loads a +// RESOURCE from them — a script, stylesheet, image, iframe, or form target. +// Both of those false positives showed up in dogfood runs. +const PREVIEW_HOST_RE = + /\b[\w-]+\.(?:vercel\.app|netlify\.app|onrender\.com|railway\.app|fly\.dev|herokuapp\.com|pages\.dev)\b/i; + +// Stock-photo CDNs. Not a defect on its own — only reported when a page has +// stock imagery and no original imagery at all. +const STOCK_IMAGE_RE = + /\b(?:images\.unsplash\.com|source\.unsplash\.com|images\.pexels\.com|[\w.-]*shutterstock\.com|[\w.-]*istockphoto\.com|[\w.-]*gettyimages\.com|[\w.-]*stock\.adobe\.com|placehold(?:er)?\.(?:co|it|com)|via\.placeholder\.com|picsum\.photos|dummyimage\.com)\b/i; + +const PLACEHOLDER_ALT_RE = + /^(?:image|img|photo|picture|placeholder|alt text|alt|logo|icon|graphic|untitled|screenshot|thumbnail|banner|dsc[_-]?\d+|img[_-]?\d+|image\d*|photo\d*|asset\d*)$/i; + +const DEPRECATED_TAGS = ["center", "font", "marquee", "blink", "big", "strike", "tt", "frame", "frameset"]; + +// --------------------------------------------------------------------------- +// Page preparation +// --------------------------------------------------------------------------- + +/** Turn raw HTML into the normalized shape the analyzers consume. */ +export function toSlopPage(input: { url: string; status: number; html: string }): SlopPage { + const $ = cheerio.load(input.html || ""); + const $body = $("body").clone(); + $body.find("script, style, noscript, template, svg").remove(); + const text = $body.text().replace(/\s+/g, " ").trim(); + return { + url: input.url, + status: input.status, + html: input.html || "", + text, + words: text ? text.split(/\s+/).length : 0, + title: $("title").first().text().trim(), + description: ($("meta[name='description']").attr("content") ?? "").trim(), + h1: $("h1").first().text().replace(/\s+/g, " ").trim(), + }; +} + +function countMatches(haystack: string, re: RegExp): { count: number; samples: string[] } { + // Caller-supplied regexes are reused across pages, so always work on a + // fresh copy — a stateful lastIndex would silently skip matches. + const rx = new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`); + const samples: string[] = []; + let count = 0; + let m: RegExpExecArray | null; + while ((m = rx.exec(haystack)) !== null) { + count++; + if (samples.length < 5) samples.push(m[0].slice(0, 120)); + if (m[0] === "") rx.lastIndex++; // guard against zero-length matches + if (count > 500) break; // pathological page; the number is already damning + } + return { count, samples }; +} + +/** Visible text with script/style already gone, lowercased for phrase search. */ +function fillerHits(text: string): { count: number; samples: string[] } { + const lower = text.toLowerCase(); + const samples: string[] = []; + let count = 0; + for (const phrase of FILLER_PHRASES) { + let from = 0; + for (;;) { + const at = lower.indexOf(phrase, from); + if (at === -1) break; + count++; + if (samples.length < 8) samples.push(phrase); + from = at + phrase.length; + } + } + return { count, samples }; +} + +// --------------------------------------------------------------------------- +// Per-page analysis +// --------------------------------------------------------------------------- + +export function analyzePage(page: SlopPage): PageSlop { + const issues: SlopIssue[] = []; + const $ = cheerio.load(page.html || ""); + const text = page.text; + + // ---- content ------------------------------------------------------------ + + // Listing pages (blog indexes, tag archives) are mostly links by design, so + // the prose-quality checks below don't apply to them — flagging a blog index + // for having no statistics is noise, not a defect. + const linkCount = $("a[href]").length; + const isListing = linkCount >= 12 && page.words / linkCount < 30; + + // Placeholders / unfinished content. + const blocks = standaloneTexts($); + const placeholders = [ + ...PLACEHOLDER_ANYWHERE.filter((p) => p.re.test(text)), + ...PLACEHOLDER_STANDALONE.filter((p) => blocks.some((b) => p.re.test(b))), + ]; + if (placeholders.length > 0) { + const labels = Array.from(new Set(placeholders.map((p) => p.label))); + issues.push({ + key: "content.placeholder", + dimension: "content", + label: `Unfinished placeholder content: ${labels.join(", ")}`, + fix: "Replace the placeholder copy with real content, or unpublish the page until it's written. Placeholder text on a live page tells both readers and answer engines the page is abandoned.", + weight: 12, + count: placeholders.length, + samples: labels, + }); + } + + // Filler phrasing, measured as density so long pages aren't punished for + // length alone. + const filler = fillerHits(text); + // Both an absolute floor and a density floor must be crossed. One stock + // phrase in a short page is ordinary writing, not slop — requiring 3+ hits + // keeps us off well-written pages that happen to say "when it comes to". + if (page.words >= 120 && filler.count >= 3) { + const per1k = (filler.count / page.words) * 1000; + if (per1k >= 1.2) { + issues.push({ + key: "content.filler", + dimension: "content", + label: `${filler.count} filler phrase${filler.count === 1 ? "" : "s"} (${per1k.toFixed(1)} per 1,000 words)`, + fix: `Cut or rewrite the padding: ${filler.samples + .slice(0, 4) + .map((s) => `"${s}"`) + .join(", ")}. Replace each with a specific claim — a number, a name, or a concrete outcome. Answer engines quote specifics and skip padding.`, + weight: per1k >= 4 ? 9 : per1k >= 2.4 ? 6 : 3, + count: filler.count, + samples: filler.samples, + }); + } + } + + // First-party evidence — the strongest single slop signal. A page with no + // numbers, no names, no quotes and no original imagery has nothing in it + // that couldn't have been generated without ever seeing the business. + if (page.words >= 150 && !isListing) { + const hasStat = /\b\d{1,3}(?:[.,]\d+)?\s?%|\b(?:\$|€|£)\s?\d|\b\d{2,}\s?(?:customers|users|companies|teams|sites|developers|downloads|hours|days|ms|seconds)\b/i.test(text); + const hasQuote = $("blockquote, q, cite, figcaption").length > 0; + const hasNamedProof = /\b(?:according to|as reported by|case study|our customer|source:)\b/i.test(text); + const imgs = $("img[src]").map((_, el) => $(el).attr("src") ?? "").get(); + const hasOriginalImage = imgs.some((src) => src && !STOCK_IMAGE_RE.test(src)); + const hasCode = $("pre, code").length > 0; + const hasTable = $("table").length > 0; + const signals = [hasStat, hasQuote, hasNamedProof, hasOriginalImage, hasCode, hasTable].filter(Boolean).length; + if (signals <= 1) { + issues.push({ + key: "content.no_first_party_evidence", + dimension: "content", + label: + signals === 0 + ? "No first-party evidence — no numbers, quotes, tables, code, or original images" + : "Almost no first-party evidence (1 signal of 6)", + fix: "Add at least two things only you could publish: a real metric with its source, a named customer quote, a comparison table, a screenshot of your own product, or a code sample. This is what separates a page worth citing from a page worth skipping.", + weight: signals === 0 ? 10 : 5, + count: signals, + samples: [ + `stat:${hasStat}`, + `quote:${hasQuote}`, + `named-proof:${hasNamedProof}`, + `original-image:${hasOriginalImage}`, + `code:${hasCode}`, + `table:${hasTable}`, + ], + }); + } + } + + // Thin content — a published page with almost nothing on it. + if (page.status >= 200 && page.status < 300 && page.words < 150 && !isListing) { + issues.push({ + key: "content.thin", + dimension: "content", + label: `Thin page — only ${page.words} visible word${page.words === 1 ? "" : "s"}`, + fix: "Either expand this to 300+ words of substantive content or remove it from the sitemap and internal nav. Thin pages dilute the crawl budget an answer engine spends on you.", + weight: page.words < 50 ? 7 : 4, + count: page.words, + }); + } + + // Stale dates — a footer stuck two years back reads as abandoned. + const thisYear = new Date().getUTCFullYear(); + const copyrightYears = Array.from(text.matchAll(/(?:©|\(c\)|copyright)\s*(?:\d{4}\s*[–-]\s*)?(\d{4})/gi)).map((m) => + parseInt(m[1]!, 10), + ); + const newestCopyright = copyrightYears.length > 0 ? Math.max(...copyrightYears) : null; + if (newestCopyright !== null && newestCopyright < thisYear - 1) { + issues.push({ + key: "content.stale_copyright", + dimension: "content", + label: `Copyright notice says ${newestCopyright} (${thisYear - newestCopyright} years behind)`, + fix: `Render the year dynamically instead of hardcoding it — e.g. \`© {new Date().getFullYear()} Company\`. A stale copyright is the cheapest possible signal that nobody is minding the site.`, + weight: 6, + count: newestCopyright, + }); + } + + // High-confidence misspellings. + const misspelled: string[] = []; + for (const word of text.toLowerCase().match(/[a-z']+/g) ?? []) { + const fix = MISSPELLINGS[word]; + if (fix && !misspelled.includes(word)) misspelled.push(word); + if (misspelled.length >= 10) break; + } + if (misspelled.length > 0) { + issues.push({ + key: "content.misspelling", + dimension: "content", + label: `${misspelled.length} misspelling${misspelled.length === 1 ? "" : "s"}: ${misspelled + .slice(0, 5) + .map((w) => `"${w}" → "${MISSPELLINGS[w]}"`) + .join(", ")}`, + fix: "Fix the spellings listed in the evidence. These are unambiguous errors, not style choices — each one is a visible signal that no human proofread the page.", + weight: misspelled.length >= 4 ? 6 : 3, + count: misspelled.length, + samples: misspelled.map((w) => `${w} → ${MISSPELLINGS[w]}`), + }); + } + + // ---- code --------------------------------------------------------------- + + // Leaked template variables / JS accidents in visible text. + for (const pat of LEAKED_VALUE_PATTERNS) { + const { count, samples } = countMatches(text, pat.re); + if (count > 0) { + issues.push({ + key: "code.leaked_value", + dimension: "code", + label: `${pat.label} rendered on the page (${count}×)`, + fix: "Trace the template or data binding that produced this and give it a fallback. Visible `undefined`/`{{var}}` text gets indexed verbatim and quoted back by answer engines.", + weight: 9, + count, + samples, + }); + } + } + + // Dev/staging hosts leaked into a production page. Only URL-bearing + // attributes are inspected — a hostname mentioned in body text or JSON-LD is + // content, not a leak. + const pageHost = safeHost(page.url); + const leaked = new Set(); + const collect = (selector: string, attrs: string[], re: RegExp) => { + $(selector).each((_, el) => { + for (const attr of attrs) { + const v = $(el).attr(attr); + if (!v) continue; + const host = safeHost(/^https?:\/\//i.test(v) ? v : `http://${v.replace(/^\/+/, "")}`); + if (!host || (pageHost && host === pageHost)) continue; + if (re.test(host)) leaked.add(host); + } + }); + }; + // Hard dev hosts: anywhere a URL can appear, links included. + collect("a[href], link[href], area[href], form[action]", ["href", "action"], HARD_DEV_HOST_RE); + collect( + "script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src], object[data]", + ["src", "data"], + HARD_DEV_HOST_RE, + ); + // Preview hosts: resource positions only. + collect( + "script[src], img[src], iframe[src], source[src], video[src], audio[src], embed[src], object[data], link[href], form[action]", + ["src", "data", "href", "action"], + PREVIEW_HOST_RE, + ); + if (leaked.size > 0) { + const hosts = Array.from(leaked); + issues.push({ + key: "code.dev_artifact_host", + dimension: "code", + label: `Dev/staging host${hosts.length === 1 ? "" : "s"} referenced by production markup: ${hosts.slice(0, 3).join(", ")}`, + fix: "Replace hardcoded localhost/staging/preview hostnames with environment-driven URLs. These break for every visitor and can expose non-production infrastructure.", + weight: 8, + count: hosts.length, + samples: hosts.slice(0, 8), + }); + } + + // Debug leftovers in inline scripts + TODO markers in comments. + const inlineScripts = $("script:not([src])") + .map((_, el) => $(el).html() ?? "") + .get() + .join("\n"); + const consoleCalls = countMatches(inlineScripts, /console\.(?:log|debug|warn|dir|table)\s*\(/g); + if (consoleCalls.count > 0) { + issues.push({ + key: "code.console_left_in", + dimension: "code", + label: `${consoleCalls.count} console.* call${consoleCalls.count === 1 ? "" : "s"} left in inline script`, + fix: "Strip debug logging from shipped markup (a build-step `drop_console`, or just delete it). It leaks internal state and signals unreviewed code.", + weight: 3, + count: consoleCalls.count, + }); + } + + const comments = page.html.match(//g) ?? []; + const todoComments = comments.filter((c) => /\b(?:TODO|FIXME|XXX|HACK|BUG|WIP)\b/.test(c)); + if (todoComments.length > 0) { + issues.push({ + key: "code.todo_comment", + dimension: "code", + label: `${todoComments.length} TODO/FIXME comment${todoComments.length === 1 ? "" : "s"} shipped in HTML`, + fix: "Resolve or delete these before shipping, and strip HTML comments in your production build. They're publicly readable notes about your own unfinished work.", + weight: 3, + count: todoComments.length, + samples: todoComments.slice(0, 3).map((c) => c.replace(/\s+/g, " ").slice(0, 120)), + }); + } + + // Large commented-out markup blocks — dead code left in the page. + const bigComments = comments.filter((c) => c.length > 500 && /<\/?\w+[\s>]/.test(c)); + if (bigComments.length > 0) { + const bytes = bigComments.reduce((n, c) => n + c.length, 0); + issues.push({ + key: "code.commented_out_markup", + dimension: "code", + label: `${bigComments.length} large commented-out markup block${bigComments.length === 1 ? "" : "s"} (${(bytes / 1024).toFixed(1)} KB)`, + fix: "Delete dead markup instead of commenting it out — version control already remembers it. Every visitor and crawler downloads these bytes.", + weight: 2, + count: bigComments.length, + }); + } + + // Empty / dead-end interactive elements. + const emptyLinks = $("a[href]").filter((_, el) => { + const $el = $(el); + const label = $el.text().replace(/\s+/g, " ").trim(); + const alt = $el.find("img[alt]").attr("alt")?.trim(); + return !label && !alt && !$el.attr("aria-label") && !$el.attr("title"); + }).length; + const hashLinks = $("a").filter((_, el) => { + const href = ($(el).attr("href") ?? "").trim(); + return href === "#" || href === "" || /^javascript:\s*(?:void\(0\)|;)?$/i.test(href); + }).length; + if (emptyLinks + hashLinks > 2) { + const parts = [ + emptyLinks > 0 ? `${emptyLinks} unlabelled link${emptyLinks === 1 ? "" : "s"}` : null, + hashLinks > 0 ? `${hashLinks} placeholder href${hashLinks === 1 ? "" : "s"} (# / javascript:void)` : null, + ].filter(Boolean); + issues.push({ + key: "code.dead_links", + dimension: "code", + label: parts.join(" and "), + fix: 'Give every link real text or an aria-label, and point it at a real URL. `href="#"` navigation is invisible to crawlers and unusable with a keyboard or screen reader.', + weight: 4, + count: emptyLinks + hashLinks, + }); + } + + // Deprecated tags — a reliable marker of copy-pasted decade-old markup. + const deprecated = DEPRECATED_TAGS.filter((t) => $(t).length > 0); + if (deprecated.length > 0) { + issues.push({ + key: "code.deprecated_tags", + dimension: "code", + label: `Deprecated HTML tags in use: ${deprecated.map((t) => `<${t}>`).join(", ")}`, + fix: "Replace these with CSS equivalents. They've been non-conforming for over a decade and their rendering is not guaranteed.", + weight: 3, + count: deprecated.length, + samples: deprecated, + }); + } + + // ---- design ------------------------------------------------------------- + + // No viewport meta — the page is unusable on a phone. + if (!$("meta[name='viewport']").attr("content")) { + issues.push({ + key: "design.no_viewport", + dimension: "design", + label: "No viewport meta tag — page won't scale on mobile", + fix: 'Add ``. Without it mobile browsers render at desktop width and zoom out.', + weight: 7, + }); + } + + // Images with no intrinsic size — the classic layout-shift jank. + const imgs = $("img"); + const unsized = imgs.filter((_, el) => { + const $el = $(el); + if ($el.attr("width") && $el.attr("height")) return false; + const style = $el.attr("style") ?? ""; + return !(/\bwidth\s*:/.test(style) && /\bheight\s*:/.test(style)); + }).length; + if (imgs.length > 0 && unsized / imgs.length > 0.5 && unsized >= 3) { + issues.push({ + key: "design.unsized_images", + dimension: "design", + label: `${unsized} of ${imgs.length} images have no width/height — causes layout shift`, + fix: "Set explicit `width` and `height` (or an aspect-ratio box) on every image so the browser can reserve space before the file loads. This is the most common cause of content jumping as a page renders.", + weight: 4, + count: unsized, + }); + } + + // Placeholder alt text — worse than empty alt, because it looks handled. + const placeholderAlt = imgs + .map((_, el) => ($(el).attr("alt") ?? "").trim()) + .get() + .filter((a) => a.length > 0 && PLACEHOLDER_ALT_RE.test(a)); + if (placeholderAlt.length > 0) { + issues.push({ + key: "design.placeholder_alt", + dimension: "design", + label: `${placeholderAlt.length} image${placeholderAlt.length === 1 ? "" : "s"} with placeholder alt text: ${Array.from(new Set(placeholderAlt)).slice(0, 4).map((a) => `"${a}"`).join(", ")}`, + fix: 'Describe what each image actually shows ("Dashboard showing 42% cost reduction"), or use `alt=""` if it\'s decorative. Generic alt text passes automated checks while helping nobody.', + weight: 4, + count: placeholderAlt.length, + samples: Array.from(new Set(placeholderAlt)).slice(0, 8), + }); + } + + // Stock-only imagery. + const imgSrcs = imgs.map((_, el) => $(el).attr("src") ?? "").get().filter(Boolean); + if (imgSrcs.length >= 2) { + const stock = imgSrcs.filter((s) => STOCK_IMAGE_RE.test(s)); + if (stock.length === imgSrcs.length) { + issues.push({ + key: "design.stock_only_imagery", + dimension: "design", + label: `All ${imgSrcs.length} images are stock or placeholder services`, + fix: "Add at least one original image — a product screenshot, your team, your actual workspace. Stock-only imagery is interchangeable with every competitor's page.", + weight: 5, + count: stock.length, + samples: stock.slice(0, 4), + }); + } + } + + // Inline-style density — styling applied ad hoc instead of systematically. + const inlineStyled = $("[style]").length; + const elements = $("*").length; + if (elements > 50 && inlineStyled / elements > 0.25 && inlineStyled >= 20) { + issues.push({ + key: "design.inline_style_density", + dimension: "design", + label: `${inlineStyled} of ${elements} elements carry inline styles (${Math.round((inlineStyled / elements) * 100)}%)`, + fix: "Move repeated inline styles into classes or design tokens. Heavy inline styling means visual changes have to be made element-by-element, which is how sites drift out of visual consistency.", + weight: 3, + count: inlineStyled, + }); + } + + const points = issues.reduce((n, i) => n + i.weight, 0); + return { url: page.url, words: page.words, issues, points }; +} + +function safeHost(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Cross-page analysis +// --------------------------------------------------------------------------- + +/** 5-word shingle set, capped so a huge page can't blow up memory. */ +function shingles(text: string): Set { + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean); + const out = new Set(); + for (let i = 0; i + 5 <= words.length && out.size < 4000; i++) { + out.add(words.slice(i, i + 5).join(" ")); + } + return out; +} + +function jaccard(a: Set, b: Set): number { + if (a.size === 0 || b.size === 0) return 0; + const [small, large] = a.size <= b.size ? [a, b] : [b, a]; + let shared = 0; + for (const s of small) if (large.has(s)) shared++; + return shared / (a.size + b.size - shared); +} + +export function analyzeSite(pages: SlopPage[], stylesheets: SlopStylesheet[] = []): SlopIssue[] { + const out: SlopIssue[] = []; + const ok = pages.filter((p) => p.status >= 200 && p.status < 300); + + // Duplicate titles / descriptions — templated metadata nobody filled in. + for (const [field, get] of [ + ["title", (p: SlopPage) => p.title], + ["meta description", (p: SlopPage) => p.description], + ] as const) { + const groups = new Map(); + for (const p of ok) { + const v = get(p); + if (!v) continue; + const key = v.toLowerCase(); + groups.set(key, [...(groups.get(key) ?? []), p.url]); + } + const dupes = Array.from(groups.entries()).filter(([, urls]) => urls.length > 1); + if (dupes.length > 0) { + const affected = dupes.reduce((n, [, urls]) => n + urls.length, 0); + out.push({ + key: `code.duplicate_${field === "title" ? "title" : "description"}`, + dimension: "code", + label: `${affected} pages share ${dupes.length} duplicate ${field}${dupes.length === 1 ? "" : "s"}`, + fix: `Give every page a unique ${field} describing that page specifically. Duplicates tell an answer engine the pages are interchangeable, so it picks one and drops the rest.`, + weight: Math.min(10, 2 + affected), + count: affected, + samples: dupes.slice(0, 4).map(([v, urls]) => `"${v.slice(0, 60)}" × ${urls.length} (${urls[0]})`), + }); + } + } + + // Missing titles / H1s across the property. + const noTitle = ok.filter((p) => !p.title); + if (noTitle.length > 0) { + out.push({ + key: "code.missing_title", + dimension: "code", + label: `${noTitle.length} page${noTitle.length === 1 ? "" : "s"} with no `, + fix: "Every page needs a unique <title>. It's the single most-quoted piece of metadata in AI answers and search results.", + weight: Math.min(10, 3 + noTitle.length), + count: noTitle.length, + samples: noTitle.slice(0, 5).map((p) => p.url), + }); + } + const noH1 = ok.filter((p) => !p.h1); + if (noH1.length > 0) { + out.push({ + key: "content.missing_h1", + dimension: "content", + label: `${noH1.length} page${noH1.length === 1 ? "" : "s"} with no <h1>`, + fix: "Add one H1 per page stating what the page is about. Answer engines use it as the page's headline claim.", + weight: Math.min(8, 2 + noH1.length), + count: noH1.length, + samples: noH1.slice(0, 5).map((p) => p.url), + }); + } + + // Near-duplicate bodies — the signature of mass-generated pages. + const substantial = ok.filter((p) => p.words >= 200).slice(0, 50); + const shingled = substantial.map((p) => ({ url: p.url, set: shingles(p.text) })); + const pairs: Array<{ a: string; b: string; sim: number }> = []; + for (let i = 0; i < shingled.length; i++) { + for (let j = i + 1; j < shingled.length; j++) { + const sim = jaccard(shingled[i]!.set, shingled[j]!.set); + if (sim >= 0.7) pairs.push({ a: shingled[i]!.url, b: shingled[j]!.url, sim }); + } + } + if (pairs.length > 0) { + pairs.sort((x, y) => y.sim - x.sim); + const affected = new Set(pairs.flatMap((p) => [p.a, p.b])); + out.push({ + key: "content.near_duplicate", + dimension: "content", + label: `${affected.size} pages are near-duplicates of each other (${pairs.length} pair${pairs.length === 1 ? "" : "s"} ≥70% identical)`, + fix: "Consolidate these into one strong page and redirect the rest, or rewrite each to cover something genuinely different. Near-duplicate pages are the clearest fingerprint of scaled content, and they compete with each other instead of ranking.", + weight: Math.min(14, 4 + affected.size), + count: affected.size, + samples: pairs.slice(0, 5).map((p) => `${(p.sim * 100).toFixed(0)}% — ${p.a} ≈ ${p.b}`), + }); + } + + // Boilerplate intros — same opening sentence across many pages. + const intros = new Map<string, string[]>(); + for (const p of ok) { + if (p.words < 80) continue; + const intro = p.text.slice(0, 120).toLowerCase().replace(/[^a-z0-9 ]/g, "").trim(); + if (intro.length < 40) continue; + intros.set(intro, [...(intros.get(intro) ?? []), p.url]); + } + const repeatedIntro = Array.from(intros.entries()).filter(([, urls]) => urls.length >= 3); + if (repeatedIntro.length > 0) { + const affected = repeatedIntro.reduce((n, [, urls]) => n + urls.length, 0); + out.push({ + key: "content.boilerplate_intro", + dimension: "content", + label: `${affected} pages open with an identical first sentence`, + fix: "Write a page-specific opening for each. A shared intro means the first thing every crawler reads is the same, so nothing distinguishes the pages.", + weight: Math.min(8, 2 + repeatedIntro.length * 2), + count: affected, + samples: repeatedIntro.slice(0, 3).map(([intro, urls]) => `"${intro.slice(0, 60)}…" × ${urls.length}`), + }); + } + + // ---- design system drift (needs stylesheets) ---------------------------- + if (stylesheets.length > 0) { + const css = stylesheets.map((s) => s.css).join("\n"); + + // Palette sprawl: dozens of one-off hex values means colors are being + // eyeballed per component instead of taken from tokens. + // + // Custom-property declarations are stripped first, because a design system + // legitimately DEFINES a full ramp there (Tailwind's theme emits ~100 hex + // steps whether or not you use them). Counting those punished sites for + // having tokens, which is backwards — after stripping, a high count means + // colors are being written ad hoc in real declarations. + const adHocCss = css.replace(/--[\w-]+\s*:[^;}]*[;}]/g, ""); + const hexes = new Set( + (adHocCss.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).map((h) => normalizeHex(h)).filter(Boolean) as string[], + ); + if (hexes.size > 60) { + out.push({ + key: "design.palette_sprawl", + dimension: "design", + label: `${hexes.size} distinct hex colors across ${stylesheets.length} stylesheet${stylesheets.length === 1 ? "" : "s"}`, + fix: "Consolidate to a token palette (CSS custom properties) of roughly 10–20 values with defined roles. Dozens of near-identical one-off colors is why a site looks subtly inconsistent page to page.", + weight: hexes.size > 120 ? 5 : 3, + count: hexes.size, + samples: Array.from(hexes).slice(0, 10), + }); + } + + // Typography sprawl. + const families = new Set( + (css.match(/font-family\s*:\s*([^;}]+)/gi) ?? []).map((d) => + d.replace(/font-family\s*:\s*/i, "").split(",")[0]!.trim().replace(/['"]/g, "").toLowerCase(), + ), + ); + families.delete("inherit"); + families.delete(""); + if (families.size > 6) { + out.push({ + key: "design.font_sprawl", + dimension: "design", + label: `${families.size} distinct font families declared`, + fix: "Cut to two or three typefaces (one display, one text, optionally one mono). Every extra family is another web font to download and another way the page looks unplanned.", + weight: 3, + count: families.size, + samples: Array.from(families).slice(0, 10), + }); + } + + // !important density — specificity wars, the CSS smell of accumulated + // patch-on-patch fixes. + const importants = (css.match(/!\s*important/gi) ?? []).length; + const rules = (css.match(/\{/g) ?? []).length || 1; + if (importants >= 25 && importants / rules > 0.08) { + out.push({ + key: "design.important_overuse", + dimension: "design", + label: `${importants} \`!important\` declarations (${((importants / rules) * 100).toFixed(0)}% of rules)`, + fix: "Untangle the specificity instead of overriding it. Heavy `!important` use means each new style change fights the last one, and it's why small visual fixes start breaking unrelated pages.", + weight: 3, + count: importants, + }); + } + } + + return out; +} + +function normalizeHex(h: string): string | null { + const v = h.slice(1).toLowerCase(); + if (v.length === 3) return `#${v[0]}${v[0]}${v[1]}${v[1]}${v[2]}${v[2]}`; + if (v.length === 6) return `#${v}`; + if (v.length === 8) return `#${v.slice(0, 6)}`; // ignore alpha + return null; +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +// Slop points are converted to a 0–100 score with a saturating curve: the +// first few defects move the number a lot, and a genuinely broken site pins +// near 100 without a single check being able to dominate. +export function slopScore(pages: PageSlop[], siteIssues: SlopIssue[]): number { + const pageCount = Math.max(1, pages.length); + const perPage = pages.reduce((n, p) => n + p.points, 0) / pageCount; + const sitePoints = siteIssues.reduce((n, i) => n + i.weight, 0); + // ~25 points/page or ~40 site-wide points lands around 60 ("Sloppy"). + const raw = perPage / 25 + sitePoints / 40; + return Math.round(100 * (1 - Math.exp(-1.1 * raw))); +} + +export function slopGrade(score: number): SlopGrade { + if (score <= 8) return "Pristine"; + if (score <= 25) return "Clean"; + if (score <= 50) return "Some slop"; + if (score <= 75) return "Sloppy"; + return "Slop factory"; +} + +export function buildSlopReport(pages: SlopPage[], stylesheets: SlopStylesheet[] = []): SlopReport { + const analyzed = pages.map(analyzePage); + const siteIssues = analyzeSite(pages, stylesheets); + const score = slopScore(analyzed, siteIssues); + const byDimension: Record<SlopDimension, number> = { content: 0, code: 0, design: 0 }; + for (const i of [...analyzed.flatMap((p) => p.issues), ...siteIssues]) { + byDimension[i.dimension] += i.weight; + } + return { + score, + grade: slopGrade(score), + pages: analyzed.sort((a, b) => b.points - a.points), + siteIssues: siteIssues.sort((a, b) => b.weight - a.weight), + byDimension, + totals: { + pages: pages.length, + issues: analyzed.reduce((n, p) => n + p.issues.length, 0) + siteIssues.length, + words: pages.reduce((n, p) => n + p.words, 0), + }, + }; +} + +// --------------------------------------------------------------------------- +// Findings + markdown +// --------------------------------------------------------------------------- + +export const SLOP_SECTION = "Slop Score"; + +const DIMENSION_LABEL: Record<SlopDimension, string> = { + content: "Content", + code: "Code", + design: "Design", +}; + +/** Per-page recommendation findings, worst pages first. */ +export function slopFindings(report: SlopReport, maxPages = 50): Finding[] { + const out: Finding[] = []; + + // Headline: the shareable number. Excluded from the AEO score in engine.ts + // so it doesn't double-count the sub-checks it summarizes. + out.push({ + section: SLOP_SECTION, + check_key: "slop.score", + status: report.score <= 25 ? "pass" : report.score <= 50 ? "warn" : "fail", + title: `Slop Score: ${report.score}/100 — ${report.grade}`, + detail: + `Scanned ${report.totals.pages} page${report.totals.pages === 1 ? "" : "s"} (${report.totals.words.toLocaleString()} words) and found ${report.totals.issues} issue${report.totals.issues === 1 ? "" : "s"}.\n\n` + + `Slop points by dimension — content ${report.byDimension.content}, code ${report.byDimension.code}, design ${report.byDimension.design}.\n\n` + + `0 is pristine, 100 is maximum slop. This measures observable defects — placeholder text, duplicate pages, leaked template variables, missing evidence, stale dates — not whether anything was written by AI.`, + evidence: { + score: report.score, + grade: report.grade, + byDimension: report.byDimension, + totals: report.totals, + }, + priority: 5, + }); + + // Site-wide issues. + for (const issue of report.siteIssues) { + out.push({ + section: SLOP_SECTION, + check_key: `slop.site.${issue.key}`, + status: issue.weight >= 8 ? "fail" : "warn", + title: `${DIMENSION_LABEL[issue.dimension]} — ${issue.label}`, + detail: issue.fix, + evidence: { dimension: issue.dimension, weight: issue.weight, count: issue.count, samples: issue.samples }, + priority: issue.weight >= 10 ? 2 : issue.weight >= 5 ? 3 : 4, + }); + } + + // Systemic rollups. When the same defect appears on many pages it's a + // template bug, not 30 content bugs — leading with "fix your post template" + // is both better advice and a shorter to-do list. These are informational: + // the per-page findings below still carry the detail, and slop-engine.ts + // leaves rollups out of the score so nothing is counted twice. + const byKey = new Map<string, { issue: SlopIssue; urls: string[] }>(); + for (const p of report.pages) { + for (const i of p.issues) { + const entry = byKey.get(i.key); + if (entry) entry.urls.push(p.url); + else byKey.set(i.key, { issue: i, urls: [p.url] }); + } + } + for (const { issue, urls } of Array.from(byKey.values()).sort((a, b) => b.urls.length - a.urls.length)) { + if (urls.length < 5) continue; + out.push({ + section: SLOP_SECTION, + check_key: `slop.systemic.${issue.key}`, + status: urls.length >= 10 ? "fail" : "warn", + title: `${DIMENSION_LABEL[issue.dimension]} — ${urls.length} pages share one defect: ${issue.label.replace(/^\d+ (?:of \d+ )?/, "")}`, + detail: + `This appears on ${urls.length} of the ${report.totals.pages} pages crawled, which means it lives in a shared template or component rather than in the content. Fix it once there and every page is fixed.\n\n${issue.fix}`, + evidence: { + dimension: issue.dimension, + issueKey: issue.key, + pages: urls.length, + systemic: true, + urls: urls.slice(0, 25), + }, + priority: urls.length >= 10 ? 2 : 3, + }); + } + + // One finding per page, so each is independently fixable (and routable to a + // GitHub auto-fix PR, which takes a single finding at a time). + for (const page of report.pages.slice(0, maxPages)) { + if (page.issues.length === 0) continue; + out.push({ + section: SLOP_SECTION, + check_key: `slop.page.${slugForUrl(page.url)}`, + status: page.points >= 15 ? "fail" : "warn", + title: `${pathOf(page.url)} — ${page.issues.length} issue${page.issues.length === 1 ? "" : "s"} (${page.points} slop points)`, + detail: page.issues + .map((i) => `**${DIMENSION_LABEL[i.dimension]}: ${i.label}**\n${i.fix}`) + .join("\n\n"), + evidence: { + url: page.url, + words: page.words, + points: page.points, + issues: page.issues.map((i) => ({ + key: i.key, + dimension: i.dimension, + label: i.label, + fix: i.fix, + count: i.count, + samples: i.samples, + })), + }, + priority: page.points >= 25 ? 2 : page.points >= 12 ? 3 : 4, + }); + } + + return out; +} + +function pathOf(url: string): string { + try { + const u = new URL(url); + return u.pathname + u.search || "/"; + } catch { + return url; + } +} + +function slugForUrl(url: string): string { + const p = pathOf(url).replace(/^\/+|\/+$/g, ""); + return (p || "home").toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 60); +} + +export function slopMarkdown(input: { + targetUrl: string; + report: SlopReport; + crawled: number; + capped: boolean; + durationMs: number; + maxPages: number; +}): string { + const { targetUrl, report, crawled, capped, durationMs, maxPages } = input; + const lines: string[] = []; + lines.push(`# Slop Score — ${targetUrl}`); + lines.push(""); + lines.push(`## ${report.score}/100 — ${report.grade}`); + lines.push(""); + lines.push( + `0 is pristine, 100 is maximum slop. This is a measure of **observable defects** — placeholder copy, duplicate pages, leaked template variables, missing first-party evidence, stale dates, unlabelled links, design drift. It does **not** estimate whether anything was written by AI.`, + ); + lines.push(""); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Pages crawled | ${crawled}${capped ? ` (capped at ${maxPages})` : ""} |`); + lines.push(`| Words analyzed | ${report.totals.words.toLocaleString()} |`); + lines.push(`| Issues found | ${report.totals.issues} |`); + lines.push(`| Content slop points | ${report.byDimension.content} |`); + lines.push(`| Code slop points | ${report.byDimension.code} |`); + lines.push(`| Design slop points | ${report.byDimension.design} |`); + lines.push(`| Duration | ${(durationMs / 1000).toFixed(1)}s |`); + lines.push(""); + + if (report.siteIssues.length > 0) { + lines.push(`## Site-wide issues`); + lines.push(""); + for (const i of report.siteIssues) { + lines.push(`### ${DIMENSION_LABEL[i.dimension]} — ${i.label}`); + lines.push(""); + lines.push(i.fix); + if (i.samples?.length) { + lines.push(""); + lines.push(i.samples.map((s) => `- \`${s}\``).join("\n")); + } + lines.push(""); + } + } + + const withIssues = report.pages.filter((p) => p.issues.length > 0); + if (withIssues.length === 0) { + lines.push(`## Per-page findings`); + lines.push(""); + lines.push(`No page-level slop found across ${crawled} pages. Nice work.`); + lines.push(""); + } else { + lines.push(`## Per-page findings (${withIssues.length} page${withIssues.length === 1 ? "" : "s"}, worst first)`); + lines.push(""); + lines.push(`| Page | Slop points | Issues |`); + lines.push(`| --- | ---: | ---: |`); + for (const p of withIssues.slice(0, maxPages)) { + lines.push(`| ${pathOf(p.url)} | ${p.points} | ${p.issues.length} |`); + } + lines.push(""); + for (const p of withIssues.slice(0, maxPages)) { + lines.push(`### ${pathOf(p.url)}`); + lines.push(""); + lines.push(`${p.url} — ${p.words.toLocaleString()} words, ${p.points} slop points`); + lines.push(""); + for (const i of p.issues) { + lines.push(`- **${DIMENSION_LABEL[i.dimension]}: ${i.label}**`); + lines.push(` ${i.fix}`); + if (i.samples?.length) { + lines.push(` Evidence: ${i.samples.slice(0, 5).map((s) => `\`${s}\``).join(", ")}`); + } + } + lines.push(""); + } + } + + const clean = report.pages.filter((p) => p.issues.length === 0); + if (clean.length > 0) { + lines.push(`## Clean pages (${clean.length})`); + lines.push(""); + lines.push(clean.slice(0, 50).map((p) => `- ${pathOf(p.url)}`).join("\n")); + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/lib/audit/slop-engine.ts b/lib/audit/slop-engine.ts new file mode 100644 index 00000000..9d57e00f --- /dev/null +++ b/lib/audit/slop-engine.ts @@ -0,0 +1,337 @@ +// Slop Score engine — free, deterministic, no LLM. +// +// Unlike the `rule` engine (which samples 8 linked pages to judge AEO basics), +// this one sweeps up to 50 pages, because the most damning slop signals are +// cross-page: near-duplicate bodies, templated metadata nobody filled in, +// the same boilerplate intro on thirty pages. One page can't show you that. +// +// Page discovery: sitemap.xml (including sitemap indexes) first, since that's +// the site's own claim about what it publishes, then breadth-first from the +// homepage to fill the remaining budget. Same-origin only. +// +// It also fetches a handful of same-origin stylesheets so the design checks +// can see palette / typography / !important sprawl, which is invisible from +// HTML alone. + +import * as cheerio from "cheerio"; +import { fetchPage } from "./fetch"; +import { scoreFindings } from "./score"; +import { + buildSlopReport, + slopFindings, + slopMarkdown, + toSlopPage, + type SlopPage, + type SlopStylesheet, +} from "./checks/slop"; +import type { AuditResult, Finding } from "./types"; + +type SlopAuditResult = AuditResult & { markdown: string }; + +export const MAX_PAGES = 50; +const MAX_STYLESHEETS = 6; +const MAX_CSS_BYTES = 1.5 * 1024 * 1024; +const CONCURRENCY = 6; +const DEADLINE_MS = 3 * 60 * 1000; // stay well inside the worker's 7-min cutoff + +const SKIP_EXT_RE = + /\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|js|mjs|json|xml|txt|pdf|zip|gz|tar|mp4|webm|mp3|wav|woff2?|ttf|eot|rss|atom)(?:$|\?)/i; + +function sameOrigin(url: string, origin: string): boolean { + try { + return new URL(url).origin === origin; + } catch { + return false; + } +} + +function normalize(url: string): string { + try { + const u = new URL(url); + u.hash = ""; + // Trailing slash is not a distinct page for our purposes; collapsing it + // stops "/about" and "/about/" from eating two slots and then reporting + // themselves as near-duplicates of each other. + if (u.pathname.length > 1 && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, ""); + return u.toString(); + } catch { + return url; + } +} + +/** Pull <loc> URLs out of a sitemap or sitemap index, following nested indexes once. */ +async function sitemapUrls(origin: string, deadline: number): Promise<string[]> { + const seen = new Set<string>(); + const out: string[] = []; + + async function readSitemap(url: string, depth: number): Promise<void> { + if (depth > 1 || out.length >= MAX_PAGES * 3 || Date.now() > deadline) return; + const res = await fetchPage(url); + if (res.status < 200 || res.status >= 300 || !res.rawHtml) return; + const $ = cheerio.load(res.rawHtml, { xmlMode: true }); + + const nested = $("sitemapindex > sitemap > loc") + .map((_, el) => $(el).text().trim()) + .get() + .filter((u) => u && sameOrigin(u, origin)); + for (const n of nested.slice(0, 5)) { + if (seen.has(n)) continue; + seen.add(n); + await readSitemap(n, depth + 1); + } + + for (const loc of $("urlset > url > loc").map((_, el) => $(el).text().trim()).get()) { + if (!loc || !sameOrigin(loc, origin) || SKIP_EXT_RE.test(loc)) continue; + const n = normalize(loc); + if (seen.has(n)) continue; + seen.add(n); + out.push(n); + } + } + + await readSitemap(`${origin}/sitemap.xml`, 0); + return out; +} + +/** Run `worker` over `items` with bounded concurrency, preserving order. */ +async function mapLimit<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> { + const out: R[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const i = next++; + if (i >= items.length) return; + out[i] = await worker(items[i]!); + } + }), + ); + return out; +} + +function linksFrom(html: string, origin: string): string[] { + const $ = cheerio.load(html); + const out: string[] = []; + $("a[href]").each((_, el) => { + const href = $(el).attr("href"); + if (!href) return; + if (/^(?:mailto:|tel:|javascript:|#)/i.test(href.trim())) return; + let abs: string; + try { + abs = new URL(href, origin).toString(); + } catch { + return; + } + if (!sameOrigin(abs, origin) || SKIP_EXT_RE.test(abs)) return; + out.push(normalize(abs)); + }); + return out; +} + +function stylesheetHrefs(html: string, origin: string): string[] { + const $ = cheerio.load(html); + const out: string[] = []; + $("link[rel='stylesheet'][href], link[as='style'][href]").each((_, el) => { + const href = $(el).attr("href"); + if (!href) return; + try { + const abs = new URL(href, origin).toString(); + if (sameOrigin(abs, origin)) out.push(abs); + } catch { + /* skip */ + } + }); + return out; +} + +export async function slopAudit( + targetUrl: string, + options: { maxPages?: number } = {}, +): Promise<SlopAuditResult> { + const started = Date.now(); + const deadline = started + DEADLINE_MS; + const maxPages = Math.min(options.maxPages ?? MAX_PAGES, MAX_PAGES); + + let origin: string; + let root: string; + try { + const u = new URL(targetUrl); + origin = u.origin; + root = normalize(`${u.origin}${u.pathname}`); + } catch { + const findings: Finding[] = [ + { + section: "Slop Score", + check_key: "slop.crawl_error", + status: "fail", + title: "Could not parse the target URL", + detail: `"${targetUrl}" is not a valid absolute http(s) URL.`, + priority: 1, + }, + ]; + return { + score: scoreFindings(findings), + findings, + markdown: `# Slop Score — ${targetUrl}\n\nThe target URL could not be parsed.\n`, + summary: { + pagesCrawled: 0, + pass: 0, + warn: 0, + fail: 1, + unknown: 0, + dataFound: [], + durationMs: Date.now() - started, + }, + }; + } + + // 1. Fetch the entry page first — it seeds both link discovery and the + // stylesheet list. + const first = await fetchPage(root); + const fetched = new Map<string, SlopPage>(); + const visited = new Set<string>([root]); + if (first.rawHtml || first.status > 0) { + fetched.set(root, toSlopPage({ url: root, status: first.status, html: first.rawHtml })); + } + + if (first.status === 0 || !first.rawHtml) { + const findings: Finding[] = [ + { + section: "Slop Score", + check_key: "slop.crawl_error", + status: "fail", + title: "Could not fetch the page", + detail: `${root} returned ${first.status || "no response"}${first.error ? ` — ${first.error}` : ""}. Nothing to analyze.`, + evidence: { url: root, status: first.status, error: first.error ?? null }, + priority: 1, + }, + ]; + return { + score: scoreFindings(findings), + findings, + markdown: `# Slop Score — ${targetUrl}\n\nThe page could not be fetched (${first.status || "no response"}).\n`, + summary: { + pagesCrawled: 0, + pass: 0, + warn: 0, + fail: 1, + unknown: 0, + dataFound: [], + durationMs: Date.now() - started, + }, + }; + } + + // 2. Build the queue: sitemap first (the site's own inventory), then BFS. + const queue: string[] = []; + const enqueue = (url: string) => { + if (visited.has(url) || queue.includes(url)) return; + queue.push(url); + }; + for (const u of await sitemapUrls(origin, deadline)) enqueue(u); + for (const u of linksFrom(first.rawHtml, origin)) enqueue(u); + + // 3. Crawl until the page budget, queue, or deadline runs out. + let capped = false; + while (fetched.size < maxPages && queue.length > 0) { + if (Date.now() > deadline) { + capped = true; + break; + } + const batch = queue.splice(0, Math.min(CONCURRENCY, maxPages - fetched.size)); + for (const u of batch) visited.add(u); + const pages = await mapLimit(batch, CONCURRENCY, (u) => fetchPage(u)); + for (const p of pages) { + // Skip non-HTML responses that slipped past the extension filter. + if (p.contentType && !/text\/html|application\/xhtml/i.test(p.contentType)) continue; + if (!p.rawHtml) continue; + const url = normalize(p.url); + if (fetched.has(url)) continue; + fetched.set(url, toSlopPage({ url, status: p.status, html: p.rawHtml })); + // Keep widening only while we still have budget to spend. + if (fetched.size + queue.length < maxPages * 2) { + for (const l of linksFrom(p.rawHtml, origin)) enqueue(l); + } + } + } + if (queue.length > 0) capped = true; + + // 4. Stylesheets for the design checks. + const cssUrls = Array.from(new Set(stylesheetHrefs(first.rawHtml, origin))).slice(0, MAX_STYLESHEETS); + const stylesheets: SlopStylesheet[] = []; + if (cssUrls.length > 0 && Date.now() < deadline) { + const cssPages = await mapLimit(cssUrls, 3, (u) => fetchPage(u)); + let bytes = 0; + for (const c of cssPages) { + if (c.status < 200 || c.status >= 300 || !c.rawHtml) continue; + if (bytes + c.rawHtml.length > MAX_CSS_BYTES) break; + bytes += c.rawHtml.length; + stylesheets.push({ url: c.url, css: c.rawHtml }); + } + } + + // 5. Analyze + report. + const pages = Array.from(fetched.values()); + const report = buildSlopReport(pages, stylesheets); + const findings = slopFindings(report, maxPages); + + findings.push({ + section: "Slop Score", + check_key: "slop.coverage", + status: capped ? "warn" : "pass", + title: capped + ? `Crawl capped at ${pages.length} page${pages.length === 1 ? "" : "s"} (${maxPages}-page limit)` + : `Swept ${pages.length} page${pages.length === 1 ? "" : "s"}`, + detail: capped + ? `The site has more pages than the ${maxPages}-page budget for this scan, so pages beyond that were not analyzed. The score reflects what was crawled.` + : `Crawled every discoverable same-origin page from ${root} (sitemap.xml + internal links), plus ${stylesheets.length} stylesheet${stylesheets.length === 1 ? "" : "s"}.`, + evidence: { + pagesCrawled: pages.length, + maxPages, + capped, + stylesheets: stylesheets.map((s) => s.url), + urls: pages.map((p) => p.url), + }, + priority: 5, + }); + + // The headline slop.score finding summarizes the others, and the systemic + // rollups restate defects already counted per page — both are excluded from + // the 0-100 AEO score so nothing is counted twice. + const scored = findings.filter( + (f) => f.check_key !== "slop.score" && !f.check_key.startsWith("slop.systemic."), + ); + + return { + score: scoreFindings(scored), + findings, + markdown: slopMarkdown({ + targetUrl, + report, + crawled: pages.length, + capped, + durationMs: Date.now() - started, + maxPages, + }), + summary: { + pagesCrawled: pages.length, + pass: findings.filter((f) => f.status === "pass").length, + warn: findings.filter((f) => f.status === "warn").length, + fail: findings.filter((f) => f.status === "fail").length, + unknown: findings.filter((f) => f.status === "unknown").length, + dataFound: [], + durationMs: Date.now() - started, + // Surfaced separately so the UI and share cards can render the number + // without re-deriving it from findings. + slopScore: report.score, + slopGrade: report.grade, + slopByDimension: report.byDimension, + slopIssues: report.totals.issues, + } as AuditResult["summary"] & { + slopScore: number; + slopGrade: string; + slopByDimension: Record<string, number>; + slopIssues: number; + }, + }; +} diff --git a/lib/credits.ts b/lib/credits.ts index fb4ff59f..7726fc08 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -62,6 +62,7 @@ export type Engine = | "spec" | "dns" | "links" + | "slop" | "vu1nz" | "claude" | "openai" @@ -112,6 +113,14 @@ export const ENGINES: Record<Engine, EngineMeta> = { blurb: "Recursively crawls your root domain (powered by linkinator) and reports every broken link — 404s, dead redirects, unreachable hosts — with the page each was found on. Free.", }, + slop: { + label: "Slop Score", + cost: 0, + available: true, + popular: true, + blurb: + "Sweeps up to 50 pages and scores how careless your site looks — placeholder copy, near-duplicate pages, leaked template variables, missing first-party evidence, stale dates, design drift — with a per-page fix list. Reports observable defects, not \"was this AI-written\". Free.", + }, vu1nz: { label: "Vu1nz web scanner", cost: 0, diff --git a/supabase/migrations/20260725120000_engine_slop.sql b/supabase/migrations/20260725120000_engine_slop.sql new file mode 100644 index 00000000..b2f91baf --- /dev/null +++ b/supabase/migrations/20260725120000_engine_slop.sql @@ -0,0 +1,9 @@ +-- Add the Slop Score sweep (lib/audit/slop-engine.ts) as a free scan engine. +-- Without extending audits_engine_check, every 'slop' scan insert fails with +-- 'new row for relation "audits" violates check constraint +-- "audits_engine_check"' — the same trap the fugu and zai migrations document. + +alter table public.audits drop constraint if exists audits_engine_check; +alter table public.audits + add constraint audits_engine_check + check (engine in ('rule', 'spec', 'dns', 'links', 'slop', 'vu1nz', 'claude', 'openai', 'gemini', 'qwen', 'kimi', 'deepseek', 'zai', 'perplexity', 'fugu')); diff --git a/tests/slop.test.ts b/tests/slop.test.ts new file mode 100644 index 00000000..d652b010 --- /dev/null +++ b/tests/slop.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from "vitest"; +import { + analyzePage, + analyzeSite, + buildSlopReport, + slopFindings, + slopGrade, + slopMarkdown, + toSlopPage, +} from "@/lib/audit/checks/slop"; + +function page(html: string, url = "https://example.com/") { + return toSlopPage({ url, status: 200, html }); +} + +/** ~200 words of specific, evidence-bearing prose — the "clean" baseline. */ +const GOOD_BODY = ` +<h1>Ship faster with staged rollouts</h1> +<p>We cut deploy time at Northwind Logistics from 42 minutes to 6 minutes by batching +migrations. Their team ships 30 times a week now, up from 4.</p> +<blockquote>"Deploys stopped being a meeting." — Dana Ruiz, VP Engineering, Northwind</blockquote> +<table><tr><th>Plan</th><th>Price</th></tr><tr><td>Team</td><td>$49</td></tr></table> +<pre><code>npx shipctl rollout --staged</code></pre> +<img src="/screens/rollout.png" alt="Rollout dashboard showing a 6 minute deploy" width="800" height="400" /> +<p>Rollouts pause automatically when error rates exceed 2% over a 5 minute window. +That threshold is configurable per service. We measured a 71% drop in rollbacks across +the 340 teams using staged rollouts in 2026, and published the raw data.</p> +<p>Every plan includes audit logs, SSO, and 90 days of deploy history. Support responds +in under 3 hours on weekdays. There is no per-seat charge for read-only accounts, so +reviewers and auditors cost nothing to add.</p> +<p>Migration takes about 20 minutes for a typical monorepo. We provide a codemod that +rewrites your existing pipeline config, and the CLI validates it before the first run.</p> +`; + +const CLEAN_HTML = `<!doctype html><html lang="en"><head> +<meta name="viewport" content="width=device-width, initial-scale=1" /> +<title>Staged rollouts — Shipctl + +${GOOD_BODY}`; + +describe("slop: clean page", () => { + it("finds no issues on a well-built, evidence-rich page", () => { + const r = analyzePage(page(CLEAN_HTML)); + expect(r.issues).toEqual([]); + expect(r.points).toBe(0); + }); + + it("scores a clean single-page site as Pristine", () => { + const report = buildSlopReport([page(CLEAN_HTML)]); + expect(report.score).toBe(0); + expect(report.grade).toBe("Pristine"); + }); +}); + +describe("slop: content detectors", () => { + it("flags lorem ipsum and coming-soon placeholders", () => { + const r = analyzePage(page(`

Hi

Lorem ipsum dolor sit amet, coming soon!

`)); + const issue = r.issues.find((i) => i.key === "content.placeholder"); + expect(issue).toBeDefined(); + expect(issue!.samples).toContain("Lorem ipsum filler text"); + expect(issue!.dimension).toBe("content"); + }); + + // Regression guards from the dogfood run against crawlproof.com's own blog, + // where both of these read as placeholders but were legitimate prose. + it("does not flag placeholder phrases used mid-sentence in real prose", () => { + const prose = analyzePage( + page( + `

Freshness conflicts

+

If your old comparison page says a feature is coming soon, while your product page says it + launched last year, you have a freshness conflict. Success is not just seeing your brand name + in an AI answer — that can happen for the wrong reason. ${"We measured a 14% lift across 90 accounts. ".repeat(20)}

+
"It worked." — Dana Ruiz, Northwind
`, + ), + ); + expect(prose.issues.some((i) => i.key === "content.placeholder")).toBe(false); + }); + + it("still flags placeholder phrases that stand alone as an element's whole text", () => { + const standalone = analyzePage(page(`

Our pricing

Coming soon

`)); + const issue = standalone.issues.find((i) => i.key === "content.placeholder"); + expect(issue).toBeDefined(); + expect(issue!.samples).toContain('"Coming soon" placeholder'); + }); + + it("exempts listing pages from prose-quality checks", () => { + const links = Array.from({ length: 20 }, (_, i) => `Post ${i} about deploys`).join(""); + const index = analyzePage(page(`

Blog

${links}`, "https://example.com/blog")); + expect(index.issues.some((i) => i.key === "content.no_first_party_evidence")).toBe(false); + expect(index.issues.some((i) => i.key === "content.thin")).toBe(false); + }); + + it("flags filler-phrase density but not a single incidental phrase", () => { + const filler = Array.from({ length: 6 }, () => + "In today's fast-paced world it is worth noting that we delve into the digital landscape to unlock the potential of your business.", + ).join(" "); + const many = analyzePage(page(`

${filler}

`)); + expect(many.issues.some((i) => i.key === "content.filler")).toBe(true); + + // One or two stock phrases in a page of real content is ordinary writing — + // it must stay under the absolute floor, or we'd flag good pages. + const padding = "We measured a 12% drop in latency across 40 services. ".repeat(30); + const one = analyzePage(page(`

At the end of the day. ${padding}

`)); + expect(one.issues.some((i) => i.key === "content.filler")).toBe(false); + + const two = analyzePage(page(`

At the end of the day. When it comes to this. ${padding}

`)); + expect(two.issues.some((i) => i.key === "content.filler")).toBe(false); + }); + + it("flags a page with no first-party evidence", () => { + const vague = "Our platform helps teams work better together with modern tooling. ".repeat(24); + const r = analyzePage(page(`

Platform

${vague}

`)); + expect(r.issues.some((i) => i.key === "content.no_first_party_evidence")).toBe(true); + }); + + it("does not flag evidence when stats and quotes are present", () => { + const r = analyzePage(page(CLEAN_HTML)); + expect(r.issues.some((i) => i.key === "content.no_first_party_evidence")).toBe(false); + }); + + it("flags thin pages", () => { + const r = analyzePage(page(`

Contact

Email us.

`)); + const thin = r.issues.find((i) => i.key === "content.thin"); + expect(thin).toBeDefined(); + expect(thin!.count).toBeLessThan(150); + }); + + it("flags a stale copyright but accepts the current and previous year", () => { + const year = new Date().getUTCFullYear(); + const stale = analyzePage(page(`
© 2019 Acme
`)); + expect(stale.issues.some((i) => i.key === "content.stale_copyright")).toBe(true); + + const fresh = analyzePage(page(`
© ${year} Acme
`)); + expect(fresh.issues.some((i) => i.key === "content.stale_copyright")).toBe(false); + + const lastYear = analyzePage(page(`
© ${year - 1} Acme
`)); + expect(lastYear.issues.some((i) => i.key === "content.stale_copyright")).toBe(false); + }); + + it("flags high-confidence misspellings only", () => { + const bad = analyzePage(page(`

We recieve seperate payments and definately accomodate you.

`)); + const m = bad.issues.find((i) => i.key === "content.misspelling"); + expect(m).toBeDefined(); + expect(m!.count).toBe(4); + + // Product names and unusual-but-correct words must never be flagged. + const ok = analyzePage(page(`

Shipctl Kubernetes Grafana Turso libSQL Anthropic queueing

`)); + expect(ok.issues.some((i) => i.key === "content.misspelling")).toBe(false); + }); +}); + +describe("slop: code detectors", () => { + it("flags unrendered template variables and JS accidents", () => { + const r = analyzePage( + page(`

Hello {{ user.firstName }}

Total: [object Object] — undefined items

`), + ); + const leaked = r.issues.filter((i) => i.key === "code.leaked_value"); + expect(leaked.length).toBeGreaterThanOrEqual(3); + expect(leaked.some((i) => i.label.includes("{{template}}"))).toBe(true); + expect(leaked.some((i) => i.label.includes("[object Object]"))).toBe(true); + }); + + it("flags dev/staging hosts in production markup", () => { + const r = analyzePage( + page(`Admin`), + ); + const issue = r.issues.find((i) => i.key === "code.dev_artifact_host"); + expect(issue).toBeDefined(); + expect(issue!.count).toBeGreaterThanOrEqual(2); + }); + + // All three of these were false positives found by dogfooding on real sites. + it("does not flag a preview host in an outbound link", () => { + // A link aggregator linking someone's Vercel demo is content, not a leak. + const r = analyzePage( + page(`Show HN: my project`, "https://news.example.com/show"), + ); + expect(r.issues.some((i) => i.key === "code.dev_artifact_host")).toBe(false); + }); + + it("does not flag a hostname that only appears in body text or JSON-LD", () => { + const r = analyzePage( + page( + `
AEO audit for devdrafts.netlify.app
+ `, + ), + ); + expect(r.issues.some((i) => i.key === "code.dev_artifact_host")).toBe(false); + }); + + it("still flags a preview host that a resource is loaded from", () => { + const r = analyzePage(page(``)); + expect(r.issues.some((i) => i.key === "code.dev_artifact_host")).toBe(true); + }); + + it("does not flag the site's own host as a dev artifact", () => { + const r = analyzePage( + page(`About`, "https://my-app.vercel.app/"), + ); + expect(r.issues.some((i) => i.key === "code.dev_artifact_host")).toBe(false); + }); + + it("flags console calls and TODO comments left in markup", () => { + const r = analyzePage( + page(``), + ); + expect(r.issues.some((i) => i.key === "code.console_left_in")).toBe(true); + expect(r.issues.some((i) => i.key === "code.todo_comment")).toBe(true); + }); + + it("flags placeholder hrefs and unlabelled links", () => { + const r = analyzePage( + page(`abc`), + ); + expect(r.issues.some((i) => i.key === "code.dead_links")).toBe(true); + }); + + it("flags deprecated tags", () => { + const r = analyzePage(page(`
Welcome
`)); + const issue = r.issues.find((i) => i.key === "code.deprecated_tags"); + expect(issue).toBeDefined(); + expect(issue!.label).toContain("
"); + }); +}); + +describe("slop: design detectors", () => { + it("flags a missing viewport meta", () => { + const r = analyzePage(page(`T

Hi

`)); + expect(r.issues.some((i) => i.key === "design.no_viewport")).toBe(true); + }); + + it("flags placeholder alt text but accepts descriptive alt", () => { + const bad = analyzePage(page(`imageDSC_0042`)); + expect(bad.issues.some((i) => i.key === "design.placeholder_alt")).toBe(true); + + const good = analyzePage(page(`Deploy pipeline with three staged rollouts`)); + expect(good.issues.some((i) => i.key === "design.placeholder_alt")).toBe(false); + }); + + it("flags unsized images that cause layout shift", () => { + const imgs = Array.from({ length: 5 }, (_, i) => `Chart ${i} of deploy times`).join(""); + const r = analyzePage(page(`${imgs}`)); + expect(r.issues.some((i) => i.key === "design.unsized_images")).toBe(true); + }); + + it("flags stock-only imagery", () => { + const r = analyzePage( + page( + `Team collaborating at a deskLaptop on a table`, + ), + ); + expect(r.issues.some((i) => i.key === "design.stock_only_imagery")).toBe(true); + }); + + it("flags palette, font and !important sprawl in stylesheets", () => { + const hexes = Array.from({ length: 80 }, (_, i) => `.c${i}{color:#${(0x111111 + i * 7).toString(16).padStart(6, "0")}}`).join(""); + const fonts = ["Inter", "Roboto", "Lato", "Georgia", "Courier", "Arial", "Verdana"] + .map((f, i) => `.f${i}{font-family:"${f}",sans-serif}`) + .join(""); + const importants = Array.from({ length: 30 }, (_, i) => `.i${i}{margin:0 !important}`).join(""); + const issues = analyzeSite([page(CLEAN_HTML)], [{ url: "https://example.com/a.css", css: hexes + fonts + importants }]); + expect(issues.some((i) => i.key === "design.palette_sprawl")).toBe(true); + + // A design system that DEFINES a big ramp as custom properties is the + // opposite of sprawl — Tailwind emits ~100 steps — so token definitions + // must not count against it. + const tokens = `:root{${Array.from({ length: 90 }, (_, i) => `--color-x${i}:#${(0x222222 + i * 11).toString(16).padStart(6, "0")};`).join("")}} + .btn{color:var(--color-x1);background:var(--color-x2)}`; + const tokenIssues = analyzeSite([page(CLEAN_HTML)], [{ url: "https://example.com/t.css", css: tokens }]); + expect(tokenIssues.some((i) => i.key === "design.palette_sprawl")).toBe(false); + expect(issues.some((i) => i.key === "design.font_sprawl")).toBe(true); + expect(issues.some((i) => i.key === "design.important_overuse")).toBe(true); + }); +}); + +describe("slop: cross-page detectors", () => { + const dup = (n: number) => + page( + `Best CRM Software + +

Best CRM

${"Choosing the right CRM matters for your growing team because data drives revenue and revenue drives growth across every department. ".repeat(12)}

`, + `https://example.com/p${n}`, + ); + + it("flags duplicate titles and descriptions", () => { + const issues = analyzeSite([dup(1), dup(2), dup(3)]); + expect(issues.some((i) => i.key === "code.duplicate_title")).toBe(true); + expect(issues.some((i) => i.key === "code.duplicate_description")).toBe(true); + }); + + it("flags near-duplicate page bodies", () => { + const issues = analyzeSite([dup(1), dup(2), dup(3)]); + const near = issues.find((i) => i.key === "content.near_duplicate"); + expect(near).toBeDefined(); + expect(near!.count).toBe(3); + }); + + it("flags boilerplate intros repeated across pages", () => { + const issues = analyzeSite([dup(1), dup(2), dup(3)]); + expect(issues.some((i) => i.key === "content.boilerplate_intro")).toBe(true); + }); + + it("flags missing titles and h1s", () => { + const bare = page(`

${"word ".repeat(100)}

`, "https://example.com/bare"); + const issues = analyzeSite([bare]); + expect(issues.some((i) => i.key === "code.missing_title")).toBe(true); + expect(issues.some((i) => i.key === "content.missing_h1")).toBe(true); + }); + + it("does not cross-flag genuinely distinct pages", () => { + const a = page(CLEAN_HTML, "https://example.com/a"); + const b = page( + `Pricing — Shipctl + +

Pricing

Team$49
+

${"Annual billing saves 18% and includes 90 days of deploy history for every service you register. ".repeat(10)}

`, + "https://example.com/pricing", + ); + const issues = analyzeSite([a, b]); + expect(issues.some((i) => i.key === "content.near_duplicate")).toBe(false); + expect(issues.some((i) => i.key === "code.duplicate_title")).toBe(false); + }); +}); + +describe("slop: scoring and output", () => { + it("grades monotonically across the range", () => { + expect(slopGrade(0)).toBe("Pristine"); + expect(slopGrade(20)).toBe("Clean"); + expect(slopGrade(40)).toBe("Some slop"); + expect(slopGrade(60)).toBe("Sloppy"); + expect(slopGrade(90)).toBe("Slop factory"); + }); + + it("scores a sloppy site far above a clean one, and stays in range", () => { + const clean = buildSlopReport([page(CLEAN_HTML)]); + const sloppyHtml = `

Hello {{name}}

Lorem ipsum dolor sit amet. Coming soon. undefined

+
old
xyz + image +
© 2018 Acme
`; + const sloppy = buildSlopReport([page(sloppyHtml)]); + expect(sloppy.score).toBeGreaterThan(clean.score + 40); + expect(sloppy.score).toBeLessThanOrEqual(100); + expect(clean.score).toBeGreaterThanOrEqual(0); + }); + + it("emits a headline finding plus one finding per sloppy page", () => { + const pages = [ + page(`

A {{x}}

Lorem ipsum dolor sit amet

`, "https://example.com/a"), + page(`

B

Coming soon

`, "https://example.com/b"), + page(CLEAN_HTML, "https://example.com/clean"), + ]; + const report = buildSlopReport(pages); + const findings = slopFindings(report); + + const headline = findings.find((f) => f.check_key === "slop.score"); + expect(headline).toBeDefined(); + expect(headline!.title).toMatch(/^Slop Score: \d+\/100 — /); + expect(headline!.section).toBe("Slop Score"); + expect((headline!.evidence as { score: number }).score).toBe(report.score); + + // Per-page findings are keyed by path so each is independently fixable. + expect(findings.some((f) => f.check_key === "slop.page.a")).toBe(true); + expect(findings.some((f) => f.check_key === "slop.page.b")).toBe(true); + expect(findings.some((f) => f.check_key === "slop.page.clean")).toBe(false); + + // Every page finding carries actionable fix text and machine-readable evidence. + const pageFinding = findings.find((f) => f.check_key === "slop.page.a")!; + expect(pageFinding.detail).toBeTruthy(); + const ev = pageFinding.evidence as { url: string; issues: Array<{ fix: string }> }; + expect(ev.url).toBe("https://example.com/a"); + expect(ev.issues.every((i) => i.fix.length > 0)).toBe(true); + }); + + it("rolls a defect repeated across many pages up into one template fix", () => { + // Ten pages, each missing a viewport tag — a template bug, not ten bugs. + const pages = Array.from({ length: 10 }, (_, i) => + page(`P${i}

P${i}

${"Real content here. ".repeat(20)}

`, `https://example.com/p${i}`), + ); + const findings = slopFindings(buildSlopReport(pages)); + const rollup = findings.find((f) => f.check_key === "slop.systemic.design.no_viewport"); + expect(rollup).toBeDefined(); + expect(rollup!.status).toBe("fail"); + expect(rollup!.title).toContain("10 pages share one defect"); + expect(rollup!.detail).toContain("shared template"); + expect((rollup!.evidence as { pages: number }).pages).toBe(10); + + // A defect on a single page must not be rolled up. + expect(findings.some((f) => f.check_key.startsWith("slop.systemic.") && f.check_key.includes("misspelling"))).toBe(false); + }); + + it("caps per-page findings at the requested page limit", () => { + const pages = Array.from({ length: 60 }, (_, i) => + page(`

P${i}

Lorem ipsum dolor sit amet

`, `https://example.com/p${i}`), + ); + const findings = slopFindings(buildSlopReport(pages), 50); + expect(findings.filter((f) => f.check_key.startsWith("slop.page.")).length).toBe(50); + }); + + it("renders markdown with the score, per-page table and fixes", () => { + const report = buildSlopReport([page(`

A

Lorem ipsum dolor sit amet

`)]); + const md = slopMarkdown({ + targetUrl: "https://example.com", + report, + crawled: 1, + capped: false, + durationMs: 1234, + maxPages: 50, + }); + expect(md).toContain("# Slop Score — https://example.com"); + expect(md).toContain(`## ${report.score}/100`); + expect(md).toContain("Per-page findings"); + expect(md).toContain("does **not** estimate whether anything was written by AI"); + }); + + it("never accuses a page of being AI-written, and says so explicitly", () => { + const report = buildSlopReport([page(`

A

Lorem ipsum dolor sit amet

`)]); + const findings = slopFindings(report); + + // No finding may claim authorship. Checked on titles and issue labels/fixes + // — everything the reader is shown as a verdict. + const verdicts = findings.flatMap((f) => { + const ev = f.evidence as { issues?: Array<{ label: string; fix: string }> } | undefined; + return [f.title, ...(ev?.issues?.flatMap((i) => [i.label, i.fix]) ?? [])]; + }); + for (const v of verdicts) { + expect(v.toLowerCase()).not.toMatch(/ai[- ]generated|written by ai|ai probability|likely ai|ai content/); + } + + // And the headline carries the disclaimer, so nobody reads the number as an + // AI-detection result. + const headline = findings.find((f) => f.check_key === "slop.score")!; + expect(headline.detail).toContain("not whether anything was written by AI"); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 7d84d0ea..d04ea005 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -5,6 +5,7 @@ import { runAudit } from "../lib/audit/engine"; import { specAudit } from "../lib/audit/spec-engine"; import { dnsAudit } from "../lib/audit/dns-engine"; import { linksAudit } from "../lib/audit/links-engine"; +import { slopAudit } from "../lib/audit/slop-engine"; import { vu1nzAudit } from "../lib/audit/vu1nz-engine"; import { claudeAudit } from "../lib/audit/claude-engine"; import { openaiAudit } from "../lib/audit/openai-engine"; @@ -346,6 +347,7 @@ async function processJob(job: Job) { | "spec" | "dns" | "links" + | "slop" | "vu1nz" | "claude" | "openai" @@ -398,6 +400,12 @@ async function processJob(job: Job) { summary = r.summary; findings = r.findings; markdown = r.markdown; + } else if (engine === "slop") { + const r = await slopAudit(audit.target_url); + score = r.score; + summary = r.summary; + findings = r.findings; + markdown = r.markdown; } else if (engine === "vu1nz") { const repoTargets = await vu1nzRepoTargets(audit.project_id as string | null); const callbackUrl = `${siteUrl.replace(/\/$/, "")}/api/webhooks/vu1nz?audit_id=${encodeURIComponent(audit.id as string)}`;