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)}`;