- 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:
| Metric | Threshold | What 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:
| Area | Check | Severity |
|---|---|---|
| SEO | No <title> | high |
| SEO | Title under 30 / over 60 characters | med / low |
| SEO | No meta description | high |
| SEO | No <h1>, or more than one | high / med |
| SEO | No canonical link | med |
| SEO | Open Graph title or image missing | med |
| SEO | No Twitter card, no <html lang> | low |
| SEO | No mobile viewport meta | high |
| Trust | No phone or email visible | high |
| Trust | No privacy policy link | high |
| Trust | No terms link | med |
| Trust | No form, no buttons/CTAs | high |
| Trust | No favicon | low |
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.