• Jun 4, 2026
  • 6 min read

From Scores to Sentences: How My Website Audit Writes a Plain-English Diagnosis

The first version of my website audit showed four numbers: performance, SEO, links, security. Technically correct, and useless to the person I built it for. A bakery owner who sees “SEO 62” doesn’t know whether that’s bad, why, or what to do on Monday.

So the audit tool (webaudit.jalcocertech.com) has a second layer that runs after the scores: it reads the raw reports and the page itself, and turns them into one diagnosis sentence and a short list of issues, worst first. This post is about that layer.

The pipeline

flowchart LR
    LH[Lighthouse JSON] --> M[extractLighthouseMetrics]
    HTML[Page HTML + headers] --> P[detectPlatform]
    HTML --> TS[analyzeTrustAndSeo]
    SEC[Security report JSON] --> SI[security issues]
    M --> D[buildDiagnosis]
    P --> D
    TS --> D
    SI --> D
    TS --> T[Top Issues: merge + sort by severity]
    SI --> T
    D --> R[Result screen]
    T --> R

The shell scripts that produce the scores don’t change. The enhancement module (scripts/audit-enhance.js) only reads what they already wrote, plus one fresh fetch of the homepage.

Lighthouse: three numbers people can feel

The Lighthouse report is huge. I keep a handful of values and convert them into units a person recognises:

metrics: {
    lcp_ms: Math.round(num('largest-contentful-paint') || 0),
    cls: Number((num('cumulative-layout-shift') || 0).toFixed(3)),
    tbt_ms: Math.round(num('total-blocking-time') || 0),
    // ...
},
weight: {
    js_kb: Math.round(jsBytes / 1024),
    image_kb: Math.round(imageBytes / 1024),
    // ...
},

Three of them make it into sentences, because each maps to something a visitor experiences:

MetricThresholdWhat the owner reads
LCP (largest contentful paint)> 2.5 s”visitors see blank screen too long”
JavaScript weight> 500 KB”most templates ship far more than they use”
CLS (layout shift)> 0.1”clicks land on wrong elements as page reflows”

2.5 s and 0.1 are Google’s own “needs improvement” lines, so the sentence quotes a source the owner can check, not my opinion.

Platform detection: a regex, not magic

Knowing what the site is built on changes the advice. “Your WordPress has plugin bloat” is a different conversation from “your Astro site is missing a meta description”. Detection is a list of fingerprints checked against the first 12 KB of HTML plus the server and x-powered-by headers:

{ id: 'wordpress', re: /\/wp-content\/|\/wp-includes\/|\/wp-json\/|wp-emoji/i, pitch: '...' },
{ id: 'shopify',   re: /cdn\.shopify\.com|shopifycloud|x-shopid|shopify-checkout/i, pitch: '...' },
{ id: 'astro',     re: /astro-island|data-astro/, pitch: '' },

Order matters: the first match wins, so the hosted builders are checked before the frameworks. The “custom-grade” frameworks — Next, Astro, Remix, SvelteKit, Nuxt, Gatsby — are detected but carry no pitch: there is nothing to sell someone who already has a good foundation. When nothing matches, the answer is unknown with confidence 0, and the diagnosis simply doesn’t talk about the platform.

Trust and SEO: checks a human would do

analyzeTrustAndSeo loads the homepage into cheerio and asks the questions I would ask if I opened the site myself. Every finding gets an id, a short message and a severity:

AreaCheckSeverity
SEONo <title>high
SEOTitle under 30 / over 60 charactersmed / low
SEONo meta descriptionhigh
SEONo <h1>, or more than onehigh / med
SEONo canonical linkmed
SEOOpen Graph title or image missingmed
SEONo Twitter card, no <html lang>low
SEONo mobile viewport metahigh
TrustNo phone or email visiblehigh
TrustNo privacy policy linkhigh
TrustNo terms linkmed
TrustNo form, no buttons/CTAshigh
TrustNo faviconlow

The severity is the important part. “No Twitter card” and “no viewport” are both true SEO findings, but one is cosmetic and the other means the site looks broken on every phone. Without a severity, a list treats them as equals.

Security issues come from the security report

The security script writes its own JSON with structured issues — HTTPS failures, certificates about to expire, missing redirects, missing headers — in the same { id, msg, severity } shape. The server reads the latest security-*.json of the run and passes security.issues along. If the security check was skipped or failed, the list is simply empty; the result never invents a security problem it didn’t observe.

One sentence: buildDiagnosis

The diagnosis is built by appending clauses, in a fixed order:

if (lh?.metrics?.lcp_ms > 2500) {
    lines.push(`LCP is ${(lh.metrics.lcp_ms / 1000).toFixed(1)}s (Google flags >2.5s) — visitors see blank screen too long.`);
}
const highTrust = (trust?.issues || []).filter(i => i.severity === 'high');
if (highTrust.length) {
    lines.push(`${highTrust.length} conversion-killer${highTrust.length > 1 ? 's' : ''}: ${highTrust.map(i => i.id.replace(/_/g, ' ')).join(', ')}.`);
}

The order is: platform remark, speed (LCP, JS weight, CLS), high-severity trust issues, high-severity security issues, high-severity SEO issues. Only high severity reaches the sentence; medium and low live in the list. If nothing qualifies, the line says so instead of manufacturing a problem.

A real output looks like: “LCP is 3.3s (Google flags >2.5s) — visitors see blank screen too long. 2 conversion-killers: no privacy, no form.” That is something an owner can repeat to their nephew who “does websites”.

Top Issues to Fix: merge, sort, cut

The result screen merges security, trust and SEO issues into one list, sorts by severity and shows the first five:

const allIssues = [
    ...(result.security?.issues || []).map((i: any) => ({ ...i, kind: 'security' })),
    ...(result.trust?.issues || []).map((i: any) => ({ ...i, kind: 'trust' })),
    ...(result.seo?.issues || []).map((i: any) => ({ ...i, kind: 'seo' })),
];
const sevWeight = { high: 3, med: 2, low: 1 };
const top = allIssues.sort((a, b) => (sevWeight[b.severity] || 0) - (sevWeight[a.severity] || 0)).slice(0, 5);

Each row shows a coloured tag (security, trust, SEO) and the message. Five is deliberate: enough to show there’s real work, few enough to read on a phone. The full JSON report stays available behind a link for anyone who wants all of it.

Why sentences beat numbers

  • A number needs a reference; a sentence carries its own. “62” means nothing without knowing the scale and the average. “Visitors see a blank screen for 3.3 seconds” doesn’t.
  • Severity ranks the work. A flat list of twenty findings is a to-do list nobody starts. Five sorted ones is a plan.
  • Honesty is built in. Every sentence comes from a measured value or a missing tag; when there’s nothing serious, the tool says that.

Takeaways

  • Keep the scoring scripts dumb and add a separate layer that interprets.
  • Give every finding an id, a message and a severity from day one; every later feature (sorting, emails, reports) reuses that shape.
  • Quote public thresholds (Google’s LCP and CLS lines) instead of inventing your own.
  • Cap what you show. Five issues get read; fifty get ignored.

Next: expiring report links.