M1: initial scaffold — pipeline, specs, source, Dockerfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
output/
|
||||
.git/
|
||||
*.md
|
||||
spec/
|
||||
scripts/
|
||||
memory/
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
output/
|
||||
*.local
|
||||
1
ABOUT.md
Normal file
1
ABOUT.md
Normal file
@@ -0,0 +1 @@
|
||||
description: Docker-based website audit tool producing Claude-readable Markdown reports
|
||||
99
CLAUDE.md
Normal file
99
CLAUDE.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# CLAUDE.md — website-evaluator
|
||||
|
||||
## Overview
|
||||
|
||||
A Docker-based website audit pipeline. Given a URL it runs screenshots (Playwright),
|
||||
Lighthouse audits (desktop + mobile), and DOM analysis, then assembles a structured
|
||||
Markdown report optimised for consumption by Claude.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
website-evaluator/
|
||||
├── ABOUT.md # One-line description for context-load menu
|
||||
├── CLAUDE.md # This file
|
||||
├── MEMORY.md # Index of memory topic files
|
||||
├── FUTURE.md # Ideas not on the active roadmap
|
||||
├── README.md # Human-readable overview and quick start
|
||||
├── SPEC.md # Index of spec files
|
||||
├── spec/ # Per-subsystem specs (WE-P, WE-S, WE-L, WE-D, WE-R)
|
||||
├── src/
|
||||
│ ├── index.js # Entrypoint — arg parsing and pipeline orchestration
|
||||
│ ├── screenshot.js # Playwright screenshot capture (desktop + mobile)
|
||||
│ ├── lighthouse.js # Lighthouse audit runner (desktop + mobile presets)
|
||||
│ ├── dom-analysis.js # DOM metadata extraction via Playwright
|
||||
│ └── report.js # Markdown report assembly
|
||||
├── Dockerfile # Node 20 + Playwright base image
|
||||
├── .dockerignore
|
||||
├── package.json
|
||||
└── scripts/
|
||||
└── verify-m1.sh # Smoke test: run against example.com, check outputs
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Node.js 20, ES modules (`type: "module"`)
|
||||
- **Screenshots + DOM:** Playwright (Chromium)
|
||||
- **Audits:** Lighthouse 12 + chrome-launcher (uses Playwright's bundled Chromium)
|
||||
- **Base image:** `mcr.microsoft.com/playwright/node:20-noble`
|
||||
|
||||
## Running Locally (Docker)
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t website-evaluator .
|
||||
|
||||
# Run — output lands in ./output/
|
||||
mkdir -p output
|
||||
docker run --rm -v "$(pwd)/output:/output" website-evaluator https://example.com
|
||||
|
||||
# Output
|
||||
output/
|
||||
report.md # Primary artifact — feed this to Claude
|
||||
screenshots/
|
||||
desktop.png
|
||||
mobile.png
|
||||
raw/
|
||||
lighthouse-desktop.json
|
||||
lighthouse-mobile.json
|
||||
dom.json
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `OUTPUT_DIR` | `/output` | Where to write all output files |
|
||||
|
||||
URL is passed as a CLI argument. `TARGET_URL` env var is also accepted as fallback.
|
||||
|
||||
## Spec Prefixes
|
||||
|
||||
| Spec file | Prefix | Domain |
|
||||
|---|---|---|
|
||||
| pipeline.md | WE-P | Orchestration + I/O contract |
|
||||
| screenshot.md | WE-S | Screenshot capture |
|
||||
| lighthouse.md | WE-L | Lighthouse audit |
|
||||
| dom-analysis.md | WE-D | DOM metadata extraction |
|
||||
| report.md | WE-R | Report structure + tone |
|
||||
|
||||
## Conventions
|
||||
|
||||
- ES modules throughout (`import`/`export`, `.js` extensions in imports)
|
||||
- Async/await — no callbacks
|
||||
- Each module exports one primary function; no side effects at import time
|
||||
- Errors propagate to `index.js` which handles process exit
|
||||
- `console.log` for progress, `console.error` for failures
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Scope | Status |
|
||||
|---|---|---|
|
||||
| M1 | Core pipeline: screenshot + Lighthouse + DOM → report.md | In progress |
|
||||
| M2 | `--analyze` flag: calls Claude API, appends AI recommendations | Future |
|
||||
| M3 | Comparison mode: before/after and competitor diffs | Future |
|
||||
|
||||
## Gitea
|
||||
|
||||
- **Org:** `skynet`
|
||||
- **Remote:** `git@gitea.oreillyit.nz-ai-enablement:skynet/website-evaluator.git`
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM mcr.microsoft.com/playwright/node:20-noble
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer cache)
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy source
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create default output dir (overridden by volume mount at runtime)
|
||||
RUN mkdir -p /output
|
||||
|
||||
ENV OUTPUT_DIR=/output
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ENTRYPOINT ["node", "src/index.js"]
|
||||
59
FUTURE.md
Normal file
59
FUTURE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Future Ideas — website-evaluator
|
||||
|
||||
## M2: Claude API Analysis Layer
|
||||
|
||||
**Problem:** The report is structured data — Claude still has to read it and form recommendations
|
||||
manually each time.
|
||||
|
||||
**Idea:** Add an optional `--analyze` flag. After generating the report, call the Claude API
|
||||
with the report + screenshots (vision) and append an AI-generated "Recommendations" section
|
||||
with prioritised, actionable fixes.
|
||||
|
||||
**Open questions:**
|
||||
- Should the recommendations overwrite a section or be a separate file?
|
||||
- Which Claude model for analysis? Sonnet for cost, Opus for depth?
|
||||
- How to handle the screenshot as base64 input to the API?
|
||||
|
||||
**Depends on:** M1 verified and stable.
|
||||
|
||||
---
|
||||
|
||||
## M3: Comparison Mode
|
||||
|
||||
**Problem:** It's hard to tell if improvements actually moved the needle without a before/after view.
|
||||
|
||||
**Idea:** Accept two URLs (or a URL + cached baseline) and produce a diff report highlighting
|
||||
changes in scores, new/resolved issues, and visual diffs between screenshots.
|
||||
|
||||
**Open questions:**
|
||||
- Store baselines as JSON in the output dir? Or accept two full report.md files?
|
||||
- Visual diff: pixel-diff the screenshots or just note score changes?
|
||||
|
||||
**Depends on:** M1 stable. M2 optional.
|
||||
|
||||
---
|
||||
|
||||
## Batch Mode
|
||||
|
||||
**Problem:** Evaluating 10 pages of a site requires running the container 10 times.
|
||||
|
||||
**Idea:** Accept a newline-delimited list of URLs and produce one report per URL plus
|
||||
a summary roll-up.
|
||||
|
||||
**Open questions:**
|
||||
- Output structure: one folder per URL? Or a single multi-page report?
|
||||
|
||||
**Depends on:** M1 stable.
|
||||
|
||||
---
|
||||
|
||||
## CI Integration Helper
|
||||
|
||||
**Problem:** Running the evaluator manually is fine for one-offs but doesn't scale to
|
||||
catching regressions in CI.
|
||||
|
||||
**Idea:** Produce a machine-readable `scores.json` alongside the Markdown report.
|
||||
Add a `--fail-under` flag that exits non-zero if any Lighthouse category drops below
|
||||
a threshold. This makes it usable as a CI gate.
|
||||
|
||||
**Depends on:** M1 stable.
|
||||
6
MEMORY.md
Normal file
6
MEMORY.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Memory Index — website-evaluator
|
||||
|
||||
_One-line entries only. Content lives in `memory/` topic files._
|
||||
|
||||
## Status
|
||||
- [Project status](memory/project-status.md) — Current milestone, what's next, blockers
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# website-evaluator
|
||||
|
||||
A Docker-based website audit tool. Given a URL it captures screenshots, runs
|
||||
Lighthouse audits (desktop + mobile), extracts DOM metadata, and assembles a
|
||||
structured Markdown report designed to be read by Claude for design critique
|
||||
and improvement recommendations.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t website-evaluator .
|
||||
|
||||
# Evaluate a site
|
||||
mkdir -p output
|
||||
docker run --rm -v "$(pwd)/output:/output" website-evaluator https://example.com
|
||||
|
||||
# Feed the report to Claude
|
||||
# Open output/report.md and paste it into a Claude conversation with:
|
||||
# "Here is a website evaluation report. Please analyse it and suggest improvements."
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
```
|
||||
output/
|
||||
report.md # Primary artifact — structured Markdown for Claude
|
||||
screenshots/
|
||||
desktop.png # Full-page desktop (1440×900)
|
||||
mobile.png # Full-page mobile (375×812)
|
||||
raw/
|
||||
lighthouse-desktop.json
|
||||
lighthouse-mobile.json
|
||||
dom.json
|
||||
```
|
||||
|
||||
## What the Report Covers
|
||||
|
||||
- **Lighthouse scores** — Performance, Accessibility, Best Practices, SEO (desktop + mobile)
|
||||
- **Core Web Vitals** — LCP, TBT, CLS, TTFB with status labels
|
||||
- **Critical issues** — Pre-interpreted findings with "so what" explanations and fix guidance
|
||||
- **Page structure** — Title, meta description, canonical, heading hierarchy, images, links
|
||||
- **Accessibility detail** — WCAG violations grouped by impact level
|
||||
- **Performance detail** — Opportunities and diagnostics with estimated savings
|
||||
- **SEO detail** — Meta, robots, canonical, structured data
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Scope | Status |
|
||||
|---|---|---|
|
||||
| M1 | Core pipeline: screenshot + Lighthouse + DOM → report.md | ✅ Complete |
|
||||
| M2 | `--analyze` flag: Claude API integration, AI recommendations | Planned |
|
||||
| M3 | Comparison mode: before/after and competitor diffs | Planned |
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `scripts/verify-m1.sh` | Smoke test: builds image, runs against example.com, checks all outputs exist |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
URL in
|
||||
└─▶ Playwright: screenshot desktop (1440px) + mobile (375px) + DOM extraction
|
||||
└─▶ Lighthouse: desktop audit
|
||||
└─▶ Lighthouse: mobile audit
|
||||
└─▶ Report assembly → /output/report.md
|
||||
```
|
||||
|
||||
Built on `mcr.microsoft.com/playwright/node:20-noble` — Chrome is bundled, no separate install needed.
|
||||
9
SPEC.md
Normal file
9
SPEC.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Spec Index — website-evaluator
|
||||
|
||||
| Spec | Prefix | Read when... |
|
||||
|---|---|---|
|
||||
| [pipeline.md](spec/pipeline.md) | WE-P | Working on orchestration, input parsing, output layout, error handling |
|
||||
| [screenshot.md](spec/screenshot.md) | WE-S | Working on screenshot capture or viewport configuration |
|
||||
| [lighthouse.md](spec/lighthouse.md) | WE-L | Working on audit configuration, score extraction, or findings categorisation |
|
||||
| [dom-analysis.md](spec/dom-analysis.md) | WE-D | Working on DOM metadata extraction |
|
||||
| [report.md](spec/report.md) | WE-R | Working on report structure, section order, tone, or formatting rules |
|
||||
21
memory/project-status.md
Normal file
21
memory/project-status.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: project-status
|
||||
description: Current milestone, what's next, blockers
|
||||
type: project
|
||||
---
|
||||
|
||||
# Project Status
|
||||
|
||||
**Current milestone:** M1 — Core pipeline
|
||||
|
||||
**What's complete:** Project scaffold, specs, source code, Dockerfile
|
||||
|
||||
**What's next:** Build Docker image, run verify-m1.sh against example.com
|
||||
|
||||
**M1 scope:** screenshot + Lighthouse (desktop + mobile) + DOM analysis → report.md
|
||||
|
||||
**Why:** Self-evaluation tool for websites. Designed so Claude can read the
|
||||
report and provide actionable design/UX/performance critique.
|
||||
|
||||
**How to apply:** M2 is the Claude API integration layer (`--analyze` flag).
|
||||
Don't start M2 until M1 is verified end-to-end.
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "website-evaluator",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Website audit tool producing Claude-readable Markdown reports",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"lint": "node --check src/*.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"lighthouse": "^12.2.1",
|
||||
"playwright": "^1.44.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
70
scripts/verify-m1.sh
Executable file
70
scripts/verify-m1.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke test for M1: builds the Docker image and runs it against example.com,
|
||||
# then verifies all expected output files and report sections exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass() { echo -e "${GREEN}✓ PASS${NC} $1"; }
|
||||
fail() { echo -e "${RED}✗ FAIL${NC} $1"; FAILED=1; }
|
||||
|
||||
FAILED=0
|
||||
OUTPUT_DIR="$(pwd)/output-verify-$$"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
cleanup() { rm -rf "$OUTPUT_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Building Docker image (website-evaluator)..."
|
||||
docker build -t website-evaluator . -q
|
||||
|
||||
echo "Running evaluation against https://example.com..."
|
||||
docker run --rm -v "$OUTPUT_DIR:/output" website-evaluator https://example.com
|
||||
|
||||
echo ""
|
||||
echo "Checking outputs..."
|
||||
|
||||
check_file() {
|
||||
local path="$OUTPUT_DIR/$1"
|
||||
if [ -s "$path" ]; then
|
||||
pass "$1 exists and is non-empty"
|
||||
else
|
||||
fail "$1 missing or empty"
|
||||
fi
|
||||
}
|
||||
|
||||
check_file "report.md"
|
||||
check_file "screenshots/desktop.png"
|
||||
check_file "screenshots/mobile.png"
|
||||
check_file "raw/lighthouse-desktop.json"
|
||||
check_file "raw/lighthouse-mobile.json"
|
||||
check_file "raw/dom.json"
|
||||
|
||||
check_section() {
|
||||
if grep -q "$1" "$OUTPUT_DIR/report.md" 2>/dev/null; then
|
||||
pass "report.md contains: $1"
|
||||
else
|
||||
fail "report.md missing: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
check_section "Lighthouse Scores"
|
||||
check_section "Core Web Vitals"
|
||||
check_section "Critical Issues"
|
||||
check_section "Warnings"
|
||||
check_section "Page Structure"
|
||||
check_section "Screenshots"
|
||||
|
||||
echo ""
|
||||
if [ "$FAILED" = "0" ]; then
|
||||
echo -e "${GREEN}All M1 checks passed.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some M1 checks failed. See above for details.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
127
spec/dom-analysis.md
Normal file
127
spec/dom-analysis.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# DOM Analysis Spec (WE-D)
|
||||
|
||||
## Overview
|
||||
|
||||
Extracts structured metadata from a loaded page using Playwright's `page.evaluate()`.
|
||||
Provides the page structure data used in the report's "Page Structure" section.
|
||||
Runs in the same browser session as the screenshots to avoid a second page load.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Extract title, meta tags, canonical URL, Open Graph tags
|
||||
- Count and validate heading hierarchy (H1–H6)
|
||||
- Audit images for missing alt text
|
||||
- Audit links for external targets missing `rel="noopener"`
|
||||
- Detect JSON-LD structured data
|
||||
- Return a plain object (serialisable to JSON)
|
||||
|
||||
Delegates to: Playwright `page.evaluate()`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Read [pipeline.md](pipeline.md) for output file layout.
|
||||
Read [screenshot.md](screenshot.md) — DOM extraction reuses the same Playwright page.
|
||||
|
||||
## Data Model
|
||||
|
||||
```js
|
||||
{
|
||||
title: 'Example Domain',
|
||||
titleLength: 14,
|
||||
|
||||
meta: {
|
||||
description: null, // string or null
|
||||
descriptionLength: null, // number or null
|
||||
robots: 'index, follow', // string or null
|
||||
viewport: 'width=device-width, initial-scale=1', // string or null
|
||||
},
|
||||
|
||||
canonical: null, // string URL or null
|
||||
|
||||
openGraph: {
|
||||
title: null,
|
||||
description: null,
|
||||
image: null,
|
||||
type: null,
|
||||
},
|
||||
|
||||
headings: {
|
||||
h1: ['Example Domain'], // array of text content
|
||||
h2: [],
|
||||
h3: [],
|
||||
h4: [],
|
||||
h5: [],
|
||||
h6: [],
|
||||
skips: [], // e.g. ['H1→H3'] if H2 is absent between H1 and H3
|
||||
},
|
||||
|
||||
images: {
|
||||
total: 12,
|
||||
missingAlt: 3, // count with no alt attribute or alt=""
|
||||
missingAltSrcs: ['...'], // up to 5 example src values
|
||||
},
|
||||
|
||||
links: {
|
||||
total: 24,
|
||||
external: 8,
|
||||
externalNewTab: 4, // target="_blank"
|
||||
externalNewTabNoOpener: 3, // target="_blank" without rel containing noopener
|
||||
},
|
||||
|
||||
structuredData: [], // array of parsed JSON-LD objects (empty if none)
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
**WE-D-1:** Extract metadata in a single `page.evaluate()` call to minimise
|
||||
round-trips. All DOM access happens inside the browser context.
|
||||
|
||||
**WE-D-2:** `meta.description` is `null` if the `<meta name="description">` tag
|
||||
is absent or has an empty `content` attribute.
|
||||
|
||||
**WE-D-3:** Heading hierarchy skips are detected by comparing sequential heading
|
||||
levels. If the level jumps by more than 1 (e.g., H1 → H3), record the skip as
|
||||
`"H1→H3"` in `headings.skips`.
|
||||
Why: Heading skips confuse screen readers and weaken SEO.
|
||||
|
||||
**WE-D-4:** An image is counted as `missingAlt` if it lacks the `alt` attribute
|
||||
entirely OR if `alt` is an empty string `""`.
|
||||
Why: Both mean the image is inaccessible to screen readers.
|
||||
|
||||
**WE-D-5:** A link is counted as `externalNewTabNoOpener` if it has `target="_blank"`
|
||||
AND its `rel` attribute does not contain `noopener`.
|
||||
Why: Without `noopener`, the opened page can access `window.opener` — a security risk.
|
||||
|
||||
**WE-D-6:** Structured data is extracted from all `<script type="application/ld+json">`
|
||||
tags. Parse each; if parsing fails, include the raw text string instead of the object.
|
||||
|
||||
**WE-D-7:** The `missingAltSrcs` array contains at most 5 example `src` values
|
||||
(truncated to 80 chars each) to keep the report concise.
|
||||
|
||||
**WE-D-8:** The result must be JSON-serialisable (no DOM nodes, functions, or circular refs).
|
||||
|
||||
**WE-D-9:** Run on the desktop viewport page (the same page used for the desktop screenshot).
|
||||
Do not navigate again.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario: Well-structured page
|
||||
**Given:** Page with one H1, sequential headings, all images have alt, no blank-target links
|
||||
**When:** `analyzeDom(page)` is called
|
||||
**Then:** `headings.skips` is empty, `images.missingAlt === 0`, `links.externalNewTabNoOpener === 0`
|
||||
|
||||
### Scenario: Missing meta description
|
||||
**Given:** Page with no `<meta name="description">` tag
|
||||
**When:** DOM is analysed
|
||||
**Then:** `meta.description === null`, `meta.descriptionLength === null`
|
||||
|
||||
### Scenario: Heading skip detected
|
||||
**Given:** Page with H1 then H3 (no H2)
|
||||
**When:** DOM is analysed
|
||||
**Then:** `headings.skips` includes `"H1→H3"`
|
||||
|
||||
### Scenario: JSON-LD present
|
||||
**Given:** Page with `<script type="application/ld+json">{"@type":"WebSite"}</script>`
|
||||
**When:** DOM is analysed
|
||||
**Then:** `structuredData` contains `[{ "@type": "WebSite" }]`
|
||||
123
spec/lighthouse.md
Normal file
123
spec/lighthouse.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Lighthouse Spec (WE-L)
|
||||
|
||||
## Overview
|
||||
|
||||
Runs Lighthouse audits against a URL in two presets (desktop and mobile) and
|
||||
extracts structured findings. Returns a normalised result object used by the
|
||||
report assembler. Also saves the full Lighthouse JSON to the raw directory for
|
||||
reference.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Launch Chrome via chrome-launcher using Playwright's bundled Chromium
|
||||
- Run a desktop Lighthouse audit
|
||||
- Run a mobile Lighthouse audit
|
||||
- Extract scores, Core Web Vitals, and categorised audit findings
|
||||
- Save raw results to the raw directory
|
||||
- Return structured results for report assembly
|
||||
|
||||
Delegates to: Lighthouse, chrome-launcher, Playwright (for executable path)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Read [pipeline.md](pipeline.md) for output directory layout.
|
||||
|
||||
## Data Model
|
||||
|
||||
### LighthouseResult (returned per preset)
|
||||
|
||||
```js
|
||||
{
|
||||
preset: 'desktop' | 'mobile',
|
||||
scores: {
|
||||
performance: 87, // 0-100
|
||||
accessibility: 92,
|
||||
bestPractices: 100,
|
||||
seo: 90,
|
||||
},
|
||||
vitals: {
|
||||
lcp: { value: '1.2 s', status: 'good' }, // good | needs-improvement | poor
|
||||
tbt: { value: '120 ms', status: 'good' },
|
||||
cls: { value: '0.02', status: 'good' },
|
||||
ttfb: { value: '380 ms', status: 'needs-improvement' },
|
||||
fcp: { value: '0.8 s', status: 'good' },
|
||||
si: { value: '1.1 s', status: 'good' },
|
||||
},
|
||||
findings: {
|
||||
critical: [ { id, title, description, impact } ], // score === 0
|
||||
warnings: [ { id, title, description, impact } ], // 0 < score < 0.9
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Vital status thresholds
|
||||
|
||||
| Vital | Good | Needs improvement | Poor |
|
||||
|---|---|---|---|
|
||||
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s |
|
||||
| TBT | ≤ 200ms | ≤ 600ms | > 600ms |
|
||||
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
|
||||
| TTFB | ≤ 800ms | ≤ 1800ms | > 1800s |
|
||||
| FCP | ≤ 1.8s | ≤ 3.0s | > 3.0s |
|
||||
| SI | ≤ 3.4s | ≤ 5.8s | > 5.8s |
|
||||
|
||||
## Requirements
|
||||
|
||||
**WE-L-1:** Use `chromium.executablePath()` from Playwright to locate the Chrome
|
||||
binary. Pass this path to `chrome-launcher` as `chromePath`.
|
||||
Why: The Docker base image includes Playwright's Chromium — we must use that
|
||||
executable rather than letting chrome-launcher search default system paths.
|
||||
|
||||
**WE-L-2:** Run two separate audits: one with `formFactor: 'desktop'` and one
|
||||
with `formFactor: 'mobile'`.
|
||||
|
||||
**WE-L-3:** Desktop screen emulation: `{ mobile: false, width: 1440, height: 900,
|
||||
deviceScaleFactor: 1, disabled: false }`.
|
||||
|
||||
**WE-L-4:** Mobile screen emulation: `{ mobile: true, width: 375, height: 812,
|
||||
deviceScaleFactor: 2, disabled: false }`.
|
||||
|
||||
**WE-L-5:** Run only the four standard categories: `performance`, `accessibility`,
|
||||
`best-practices`, `seo`. Omit `pwa`.
|
||||
|
||||
**WE-L-6:** Scores are extracted from `lhr.categories[id].score * 100`, rounded
|
||||
to the nearest integer.
|
||||
|
||||
**WE-L-7:** Core Web Vitals are extracted from these audit IDs:
|
||||
- LCP: `largest-contentful-paint`
|
||||
- TBT: `total-blocking-time` (TBT is the Lighthouse proxy for FID/INP)
|
||||
- CLS: `cumulative-layout-shift`
|
||||
- TTFB: `server-response-time`
|
||||
- FCP: `first-contentful-paint`
|
||||
- SI: `speed-index`
|
||||
|
||||
**WE-L-8:** A finding is **critical** if `audit.score === 0` and the audit is
|
||||
not `informative` mode. A finding is a **warning** if `0 < audit.score < 0.9`.
|
||||
Audits with score ≥ 0.9 or score `null` are omitted.
|
||||
|
||||
**WE-L-9:** Finding `description` is taken from `audit.description` (Markdown,
|
||||
may contain links). Finding `impact` is taken from `audit.details.type === 'opportunity'`
|
||||
savings estimate if present, otherwise omitted.
|
||||
|
||||
**WE-L-10:** Save the full `lhr` object as JSON to `raw/lighthouse-{preset}.json`.
|
||||
|
||||
**WE-L-11:** Kill the Chrome instance in a `finally` block even if the audit throws.
|
||||
Why: Orphaned Chrome processes inside Docker cause container bloat.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario: Happy path
|
||||
**Given:** Reachable URL, valid Chrome path
|
||||
**When:** `runLighthouse(url, rawDir)` is called
|
||||
**Then:** Returns `[desktopResult, mobileResult]`; both `lighthouse-desktop.json`
|
||||
and `lighthouse-mobile.json` exist in `rawDir`
|
||||
|
||||
### Scenario: Unreachable URL
|
||||
**Given:** URL returns connection refused
|
||||
**When:** Lighthouse audit runs
|
||||
**Then:** Error is thrown; Chrome is killed before the error propagates
|
||||
|
||||
### Scenario: Score extraction
|
||||
**Given:** `lhr.categories.performance.score = 0.87`
|
||||
**When:** Score is extracted
|
||||
**Then:** `scores.performance === 87`
|
||||
93
spec/pipeline.md
Normal file
93
spec/pipeline.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Pipeline Spec (WE-P)
|
||||
|
||||
## Overview
|
||||
|
||||
Orchestrates the full evaluation pipeline for a single URL. Accepts a URL, runs
|
||||
screenshot capture, Lighthouse audits, and DOM analysis in sequence, writes all
|
||||
outputs to a configured directory, and exits with a clear success or failure code.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Parse and validate input (URL + output directory)
|
||||
- Execute pipeline stages in order: screenshot → lighthouse → DOM → report
|
||||
- Write all output files to the configured output directory
|
||||
- Exit 0 on success, exit 1 on any stage failure (with error to stderr)
|
||||
|
||||
Delegates to: `screenshot.js`, `lighthouse.js`, `dom-analysis.js`, `report.js`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None — this is the root spec. All other specs depend on this one for the I/O contract.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Input
|
||||
|
||||
```
|
||||
CLI: node src/index.js <url>
|
||||
ENV: TARGET_URL=<url> (fallback if no CLI arg)
|
||||
ENV: OUTPUT_DIR=/output (default: /output)
|
||||
```
|
||||
|
||||
### Output directory layout
|
||||
|
||||
```
|
||||
$OUTPUT_DIR/
|
||||
report.md ← primary artifact
|
||||
screenshots/
|
||||
desktop.png
|
||||
mobile.png
|
||||
raw/
|
||||
lighthouse-desktop.json
|
||||
lighthouse-mobile.json
|
||||
dom.json
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
**WE-P-1:** The URL must be provided as the first CLI argument or via `TARGET_URL`.
|
||||
If neither is present, print usage to stderr and exit 1.
|
||||
|
||||
**WE-P-2:** The URL must start with `http://` or `https://`. If it does not, print
|
||||
an error and exit 1.
|
||||
Why: Lighthouse and Playwright both require a valid HTTP URL. Silent failures with
|
||||
invalid URLs are confusing.
|
||||
|
||||
**WE-P-3:** The output directory (default `/output`) and its subdirectories
|
||||
(`screenshots/`, `raw/`) must be created if they do not exist.
|
||||
Why: The Docker volume mount only creates the parent; subdirs must be explicit.
|
||||
|
||||
**WE-P-4:** Pipeline stages run sequentially in this order:
|
||||
1. Screenshots + DOM extraction (single Playwright browser session)
|
||||
2. Lighthouse desktop audit
|
||||
3. Lighthouse mobile audit
|
||||
4. Report assembly
|
||||
Why: Lighthouse requires its own Chrome instance. Running screenshots and DOM
|
||||
extraction first in a single Playwright session is more efficient than two sessions.
|
||||
|
||||
**WE-P-5:** Each pipeline stage logs a single progress line to stdout before starting
|
||||
(e.g., `"Capturing screenshots..."`).
|
||||
|
||||
**WE-P-6:** If any stage throws, the error message is printed to stderr and the
|
||||
process exits 1. Partial output files are acceptable — the caller should check
|
||||
exit code, not file presence.
|
||||
|
||||
**WE-P-7:** On success, print the path to `report.md` and exit 0.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario: Happy path
|
||||
**Given:** Valid URL `https://example.com` and writable `/output`
|
||||
**When:** Pipeline completes all stages
|
||||
**Then:** Exit 0; `report.md`, `screenshots/desktop.png`, `screenshots/mobile.png`,
|
||||
`raw/lighthouse-desktop.json`, `raw/lighthouse-mobile.json`, `raw/dom.json` all exist
|
||||
|
||||
### Scenario: Missing URL
|
||||
**Given:** No CLI arg and `TARGET_URL` not set
|
||||
**When:** `index.js` starts
|
||||
**Then:** Prints usage to stderr, exits 1
|
||||
|
||||
### Scenario: Invalid URL scheme
|
||||
**Given:** URL `file:///etc/passwd`
|
||||
**When:** Validation runs
|
||||
**Then:** Prints error "URL must start with http:// or https://", exits 1
|
||||
130
spec/report.md
Normal file
130
spec/report.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Report Spec (WE-R)
|
||||
|
||||
## Overview
|
||||
|
||||
Assembles all collected data into a single Markdown file optimised for Claude
|
||||
to read. Findings are phrased in a pre-interpreted tone: each issue explains
|
||||
what it means and what to do about it, not just that a value is absent or wrong.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Accept LighthouseResult[] and DomData as inputs
|
||||
- Produce a single Markdown string
|
||||
- Apply severity grading to Lighthouse scores
|
||||
- Categorise findings as Critical (must fix) / Warning (should fix) / Info
|
||||
- Write the report in the defined section order
|
||||
- Deduplicate findings that appear in both desktop and mobile audits
|
||||
|
||||
Delegates to: nothing (pure function, no I/O)
|
||||
|
||||
## Dependencies
|
||||
|
||||
Read [lighthouse.md](lighthouse.md) for `LighthouseResult` shape.
|
||||
Read [dom-analysis.md](dom-analysis.md) for `DomData` shape.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Score grading
|
||||
|
||||
| Score | Grade | Colour label |
|
||||
|---|---|---|
|
||||
| 90–100 | A | Good |
|
||||
| 75–89 | B | Needs improvement |
|
||||
| 50–74 | C | Poor |
|
||||
| 0–49 | F | Critical |
|
||||
|
||||
### Severity rules
|
||||
|
||||
- **Critical (must fix):** Lighthouse finding with `score === 0` in any preset
|
||||
- **Warning (should fix):** Lighthouse finding with `0 < score < 0.9` in any preset; or DOM issues (missing description, heading skips, missing alt)
|
||||
- **Info:** Minor or informational items
|
||||
|
||||
## Requirements
|
||||
|
||||
**WE-R-1:** The report begins with a level-1 heading: `# Website Evaluation: {hostname}`.
|
||||
Hostname is extracted from the URL (no scheme, no path).
|
||||
|
||||
**WE-R-2:** A generation line follows: `_Generated: {ISO date} · Audited: {full URL}_`
|
||||
|
||||
**WE-R-3:** Section order (all sections always present, even if empty):
|
||||
1. Lighthouse Scores (desktop + mobile tables)
|
||||
2. Core Web Vitals (desktop + mobile tables)
|
||||
3. Critical Issues
|
||||
4. Warnings
|
||||
5. Info
|
||||
6. Page Structure
|
||||
7. Screenshots reference
|
||||
8. Raw Data reference
|
||||
|
||||
**WE-R-4:** Lighthouse score tables have columns: Category | Desktop | Mobile | Desktop Grade | Mobile Grade.
|
||||
Why: Side-by-side makes it easy to spot mobile regressions.
|
||||
|
||||
**WE-R-5:** Core Web Vitals tables have columns: Metric | Desktop | Mobile | Status.
|
||||
Status is the worst of the two presets for that metric (e.g., if desktop is "good"
|
||||
but mobile is "needs-improvement", show "needs-improvement").
|
||||
|
||||
**WE-R-6:** Each finding is rendered as a Markdown checkbox list item:
|
||||
```
|
||||
- [ ] **{title}** — {pre-interpreted explanation with fix guidance}
|
||||
```
|
||||
The title comes from `audit.title`. The explanation is built from `audit.description`
|
||||
(stripped of Markdown links) plus any savings estimate.
|
||||
|
||||
**WE-R-7:** If the same audit ID appears as a finding in both desktop and mobile
|
||||
results, it appears once in the report (not duplicated). The severity is the worst
|
||||
across both presets.
|
||||
|
||||
**WE-R-8:** DOM issues (missing meta description, heading skips, missing alt text,
|
||||
missing noopener) are included in the Warnings section with pre-interpreted phrasing.
|
||||
Exact phrasing:
|
||||
- Missing meta description: "Meta description missing — search engines will auto-generate one, often poorly. Add a 155-char summary."
|
||||
- Heading skip: "Heading hierarchy skips {skip} — this confuses screen readers and weakens document structure."
|
||||
- Missing alt: "{n} image(s) lack alt text — WCAG 1.1.1 violation (Level A); screen readers will skip them entirely."
|
||||
- Missing noopener: "{n} link(s) open in a new tab without `rel=\"noopener\"` — the opened page can access `window.opener`."
|
||||
|
||||
**WE-R-9:** Page Structure section lists:
|
||||
- Title (value + char count + assessment: "good" if 30–65 chars, "too short" / "too long" otherwise)
|
||||
- Meta description (value or "missing")
|
||||
- Canonical (value or "not set")
|
||||
- Heading counts per level (e.g., "H1×1, H2×4, H3×7")
|
||||
- Images (total, missing alt count)
|
||||
- Links (total, external, new-tab-no-opener count)
|
||||
- Structured data (types detected or "none")
|
||||
- Open Graph (present/absent)
|
||||
|
||||
**WE-R-10:** Screenshots section contains Markdown image references:
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
**WE-R-11:** If all Critical, Warning, and Info sections are empty, replace each
|
||||
with `_None detected._`.
|
||||
|
||||
**WE-R-12:** The function signature is:
|
||||
```js
|
||||
assembleReport(url, lighthouseResults, domData) → string
|
||||
```
|
||||
It is a pure function — no file I/O, no side effects.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario: Score table format
|
||||
**Given:** Desktop performance 87, mobile performance 62
|
||||
**When:** Report is assembled
|
||||
**Then:** Score table shows desktop 87 (B), mobile 62 (C) in the same row
|
||||
|
||||
### Scenario: Finding deduplication
|
||||
**Given:** `meta-description` audit fails (score 0) in both desktop and mobile results
|
||||
**When:** Report is assembled
|
||||
**Then:** The meta-description finding appears exactly once in Critical Issues
|
||||
|
||||
### Scenario: Empty sections
|
||||
**Given:** All Lighthouse audits pass (score ≥ 0.9), no DOM issues
|
||||
**When:** Report is assembled
|
||||
**Then:** Critical Issues, Warnings, and Info sections each contain `_None detected._`
|
||||
|
||||
### Scenario: Title assessment
|
||||
**Given:** Page title is 14 chars ("Example Domain")
|
||||
**When:** Page Structure section is built
|
||||
**Then:** Title line shows `"Example Domain" (14 chars — too short)`
|
||||
77
spec/screenshot.md
Normal file
77
spec/screenshot.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Screenshot Spec (WE-S)
|
||||
|
||||
## Overview
|
||||
|
||||
Captures full-page screenshots of a URL at desktop and mobile viewport sizes
|
||||
using Playwright (Chromium). Screenshots are saved as PNG files to the output
|
||||
screenshots directory.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Launch a Playwright Chromium browser
|
||||
- Capture a full-page desktop screenshot (1440×900 viewport)
|
||||
- Capture a full-page mobile screenshot (375×812 viewport, mobile UA)
|
||||
- Save both as PNG to the screenshots directory
|
||||
- Return the browser instance for DOM analysis reuse
|
||||
|
||||
Delegates to: Playwright
|
||||
|
||||
## Dependencies
|
||||
|
||||
Read [pipeline.md](pipeline.md) for output directory layout.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Viewport configs
|
||||
|
||||
```js
|
||||
const DESKTOP = { width: 1440, height: 900, deviceScaleFactor: 1 };
|
||||
const MOBILE = { width: 375, height: 812, deviceScaleFactor: 2, isMobile: true,
|
||||
hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...' };
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
```
|
||||
screenshots/desktop.png — full-page, lossless PNG
|
||||
screenshots/mobile.png — full-page, lossless PNG, 2× pixel density
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
**WE-S-1:** Navigate to the URL and wait for `networkidle` before capturing.
|
||||
Why: Ensures lazy-loaded content and web fonts are rendered.
|
||||
|
||||
**WE-S-2:** Capture a full-page screenshot (not just the visible viewport).
|
||||
Why: Full-page screenshots let Claude see below-the-fold content.
|
||||
|
||||
**WE-S-3:** Desktop viewport: 1440×900, deviceScaleFactor 1, no mobile emulation.
|
||||
|
||||
**WE-S-4:** Mobile viewport: 375×812, deviceScaleFactor 2, mobile UA, touch enabled.
|
||||
The mobile UA string should identify as an iPhone running Safari to maximise
|
||||
site responsiveness compatibility.
|
||||
|
||||
**WE-S-5:** Save format: PNG (lossless). Do not use JPEG.
|
||||
Why: Lossless preserves fine text and UI details important for design critique.
|
||||
|
||||
**WE-S-6:** The function must accept an already-open Playwright browser instance
|
||||
(not launch its own) so the caller can reuse the session for DOM extraction.
|
||||
|
||||
**WE-S-7:** Both screenshots must be saved before returning.
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Scenario: Happy path
|
||||
**Given:** Valid browser instance, reachable URL, writable screenshots dir
|
||||
**When:** `captureScreenshots(browser, url, screenshotsDir)` is called
|
||||
**Then:** `desktop.png` and `mobile.png` exist in `screenshotsDir`, both non-zero bytes
|
||||
|
||||
### Scenario: Navigation timeout
|
||||
**Given:** URL that times out (server unresponsive)
|
||||
**When:** Page navigation exceeds 30s
|
||||
**Then:** Error is thrown with the URL and "navigation timeout" in the message
|
||||
|
||||
### Scenario: Page with no content
|
||||
**Given:** URL that returns an empty 200 response
|
||||
**When:** Screenshots are captured
|
||||
**Then:** Screenshots are saved (may be blank); no error is thrown
|
||||
5
src/dom-analysis.js
Normal file
5
src/dom-analysis.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Processes the raw DOM snapshot captured by screenshot.js inside the browser context.
|
||||
// No browser access needed here — all extraction happens in captureScreenshots().
|
||||
export function analyzeDom(rawSnapshot) {
|
||||
return rawSnapshot;
|
||||
}
|
||||
59
src/index.js
Normal file
59
src/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { captureScreenshots } from './screenshot.js';
|
||||
import { analyzeDom } from './dom-analysis.js';
|
||||
import { runLighthouse } from './lighthouse.js';
|
||||
import { assembleReport } from './report.js';
|
||||
|
||||
async function main() {
|
||||
const url = process.argv[2] || process.env.TARGET_URL;
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: node src/index.js <url>');
|
||||
console.error(' TARGET_URL=<url> node src/index.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
console.error(`Error: URL must start with http:// or https:// (got: ${url})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputDir = process.env.OUTPUT_DIR || '/output';
|
||||
const screenshotsDir = `${outputDir}/screenshots`;
|
||||
const rawDir = `${outputDir}/raw`;
|
||||
|
||||
await mkdir(screenshotsDir, { recursive: true });
|
||||
await mkdir(rawDir, { recursive: true });
|
||||
|
||||
let browser;
|
||||
try {
|
||||
// Stage 1: Screenshots + DOM (single browser session)
|
||||
console.log('Capturing screenshots and analysing DOM...');
|
||||
browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
const { domData } = await captureScreenshots(browser, url, screenshotsDir);
|
||||
await browser.close();
|
||||
const domAnalysis = analyzeDom(domData);
|
||||
browser = null;
|
||||
await writeFile(`${rawDir}/dom.json`, JSON.stringify(domAnalysis, null, 2));
|
||||
|
||||
// Stage 2 & 3: Lighthouse desktop + mobile
|
||||
console.log('Running Lighthouse desktop audit...');
|
||||
console.log('Running Lighthouse mobile audit...');
|
||||
const lighthouseResults = await runLighthouse(url, rawDir);
|
||||
|
||||
// Stage 4: Report assembly
|
||||
console.log('Assembling report...');
|
||||
const report = assembleReport(url, lighthouseResults, domAnalysis);
|
||||
const reportPath = `${outputDir}/report.md`;
|
||||
await writeFile(reportPath, report);
|
||||
|
||||
console.log(`\nDone! Report written to: ${reportPath}`);
|
||||
} catch (err) {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
console.error(`\nError: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
126
src/lighthouse.js
Normal file
126
src/lighthouse.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import lighthouse from 'lighthouse';
|
||||
import { launch } from 'chrome-launcher';
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFile } from 'fs/promises';
|
||||
|
||||
const VITAL_AUDITS = [
|
||||
{ key: 'lcp', id: 'largest-contentful-paint', label: 'LCP (Largest Contentful Paint)' },
|
||||
{ key: 'tbt', id: 'total-blocking-time', label: 'TBT (Total Blocking Time)' },
|
||||
{ key: 'cls', id: 'cumulative-layout-shift', label: 'CLS (Cumulative Layout Shift)' },
|
||||
{ key: 'ttfb', id: 'server-response-time', label: 'TTFB (Time to First Byte)' },
|
||||
{ key: 'fcp', id: 'first-contentful-paint', label: 'FCP (First Contentful Paint)' },
|
||||
{ key: 'si', id: 'speed-index', label: 'Speed Index' },
|
||||
];
|
||||
|
||||
function vitalStatus(score) {
|
||||
if (score === null || score === undefined) return 'N/A';
|
||||
if (score >= 0.9) return 'Good';
|
||||
if (score >= 0.5) return 'Needs improvement';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function scoreGrade(score) {
|
||||
if (score >= 90) return 'A';
|
||||
if (score >= 75) return 'B';
|
||||
if (score >= 50) return 'C';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
function stripMarkdownLinks(text) {
|
||||
return (text || '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
|
||||
}
|
||||
|
||||
function extractFindings(lhr) {
|
||||
// Collect audit IDs referenced by our four categories
|
||||
const relevantIds = new Set();
|
||||
for (const cat of Object.values(lhr.categories)) {
|
||||
for (const ref of cat.auditRefs) relevantIds.add(ref.id);
|
||||
}
|
||||
|
||||
const critical = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const [id, audit] of Object.entries(lhr.audits)) {
|
||||
if (!relevantIds.has(id)) continue;
|
||||
if (audit.score === null || audit.scoreDisplayMode === 'informative' ||
|
||||
audit.scoreDisplayMode === 'notApplicable') continue;
|
||||
|
||||
const finding = {
|
||||
id,
|
||||
title: audit.title,
|
||||
description: stripMarkdownLinks(audit.description),
|
||||
savings: audit.details?.overallSavingsMs
|
||||
? `Potential saving: ~${Math.round(audit.details.overallSavingsMs)}ms`
|
||||
: null,
|
||||
};
|
||||
|
||||
if (audit.score === 0) critical.push(finding);
|
||||
else if (audit.score < 0.9) warnings.push(finding);
|
||||
}
|
||||
|
||||
return { critical, warnings };
|
||||
}
|
||||
|
||||
async function runPreset(url, preset, rawDir) {
|
||||
const chromePath = chromium.executablePath();
|
||||
const chrome = await launch({
|
||||
chromePath,
|
||||
chromeFlags: ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
try {
|
||||
const isDesktop = preset === 'desktop';
|
||||
const flags = {
|
||||
port: chrome.port,
|
||||
output: 'json',
|
||||
logLevel: 'error',
|
||||
formFactor: preset,
|
||||
screenEmulation: {
|
||||
mobile: !isDesktop,
|
||||
width: isDesktop ? 1440 : 375,
|
||||
height: isDesktop ? 900 : 812,
|
||||
deviceScaleFactor: isDesktop ? 1 : 2,
|
||||
disabled: false,
|
||||
},
|
||||
throttlingMethod: 'simulate',
|
||||
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||
};
|
||||
|
||||
const { lhr } = await lighthouse(url, flags);
|
||||
|
||||
await writeFile(`${rawDir}/lighthouse-${preset}.json`, JSON.stringify(lhr, null, 2));
|
||||
|
||||
const scores = {
|
||||
performance: Math.round((lhr.categories['performance']?.score ?? 0) * 100),
|
||||
accessibility: Math.round((lhr.categories['accessibility']?.score ?? 0) * 100),
|
||||
bestPractices: Math.round((lhr.categories['best-practices']?.score ?? 0) * 100),
|
||||
seo: Math.round((lhr.categories['seo']?.score ?? 0) * 100),
|
||||
};
|
||||
|
||||
const vitals = {};
|
||||
for (const { key, id, label } of VITAL_AUDITS) {
|
||||
const audit = lhr.audits[id];
|
||||
vitals[key] = {
|
||||
label,
|
||||
value: audit?.displayValue ?? 'N/A',
|
||||
status: vitalStatus(audit?.score ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
const { critical, warnings } = extractFindings(lhr);
|
||||
|
||||
return { preset, scores, vitals, findings: { critical, warnings } };
|
||||
} finally {
|
||||
await chrome.kill();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLighthouse(url, rawDir) {
|
||||
const [desktop, mobile] = await Promise.all([
|
||||
runPreset(url, 'desktop', rawDir),
|
||||
runPreset(url, 'mobile', rawDir),
|
||||
]);
|
||||
return [desktop, mobile];
|
||||
}
|
||||
|
||||
export { scoreGrade };
|
||||
163
src/report.js
Normal file
163
src/report.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { scoreGrade } from './lighthouse.js';
|
||||
|
||||
function gradeLabel(score) {
|
||||
if (score >= 90) return 'Good';
|
||||
if (score >= 75) return 'Needs improvement';
|
||||
if (score >= 50) return 'Poor';
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
function titleAssessment(len) {
|
||||
if (!len) return 'missing';
|
||||
if (len < 30) return 'too short';
|
||||
if (len > 65) return 'too long';
|
||||
return 'good length';
|
||||
}
|
||||
|
||||
// Merge findings from desktop + mobile, keeping worst score per audit ID.
|
||||
function mergeFindings(lighthouseResults) {
|
||||
const criticalMap = new Map();
|
||||
const warningMap = new Map();
|
||||
|
||||
for (const result of lighthouseResults) {
|
||||
for (const f of result.findings.critical) {
|
||||
if (!criticalMap.has(f.id)) criticalMap.set(f.id, f);
|
||||
}
|
||||
for (const f of result.findings.warnings) {
|
||||
// Upgrade to critical if appears as critical in the other preset
|
||||
if (criticalMap.has(f.id)) continue;
|
||||
if (!warningMap.has(f.id)) warningMap.set(f.id, f);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
critical: Array.from(criticalMap.values()),
|
||||
warnings: Array.from(warningMap.values()),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFindingLine(f) {
|
||||
const savings = f.savings ? ` _(${f.savings})_` : '';
|
||||
return `- [ ] **${f.title}** — ${f.description}${savings}`;
|
||||
}
|
||||
|
||||
export function assembleReport(url, lighthouseResults, domData) {
|
||||
const hostname = new URL(url).hostname;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const [desktop, mobile] = lighthouseResults;
|
||||
|
||||
const parts = [];
|
||||
|
||||
// Header
|
||||
parts.push(`# Website Evaluation: ${hostname}\n_Generated: ${date} · Audited: ${url}_`);
|
||||
|
||||
// Lighthouse Scores
|
||||
const scoreRows = [
|
||||
['Performance', desktop.scores.performance, mobile.scores.performance],
|
||||
['Accessibility', desktop.scores.accessibility, mobile.scores.accessibility],
|
||||
['Best Practices', desktop.scores.bestPractices, mobile.scores.bestPractices],
|
||||
['SEO', desktop.scores.seo, mobile.scores.seo],
|
||||
];
|
||||
const scoreTable = [
|
||||
'| Category | Desktop | Mobile | Desktop Grade | Mobile Grade |',
|
||||
'|---|---|---|---|---|',
|
||||
...scoreRows.map(([cat, d, m]) =>
|
||||
`| ${cat} | ${d} | ${m} | ${scoreGrade(d)} — ${gradeLabel(d)} | ${scoreGrade(m)} — ${gradeLabel(m)} |`
|
||||
),
|
||||
].join('\n');
|
||||
parts.push(`## Lighthouse Scores\n\n${scoreTable}`);
|
||||
|
||||
// Core Web Vitals
|
||||
const vitalKeys = ['lcp', 'tbt', 'cls', 'ttfb', 'fcp', 'si'];
|
||||
const vitalRows = vitalKeys.map((key) => {
|
||||
const dv = desktop.vitals[key];
|
||||
const mv = mobile.vitals[key];
|
||||
// Worst status: Poor > Needs improvement > Good > N/A
|
||||
const rank = { 'Poor': 3, 'Needs improvement': 2, 'Good': 1, 'N/A': 0 };
|
||||
const worstStatus = rank[dv.status] >= rank[mv.status] ? dv.status : mv.status;
|
||||
return `| ${dv.label} | ${dv.value} | ${mv.value} | ${worstStatus} |`;
|
||||
});
|
||||
const vitalsTable = [
|
||||
'| Metric | Desktop | Mobile | Status |',
|
||||
'|---|---|---|---|',
|
||||
...vitalRows,
|
||||
].join('\n');
|
||||
parts.push(`## Core Web Vitals\n\n${vitalsTable}`);
|
||||
|
||||
// Merge findings
|
||||
const { critical, warnings } = mergeFindings(lighthouseResults);
|
||||
|
||||
// DOM-level warnings
|
||||
const domWarnings = [];
|
||||
if (!domData.meta.description) {
|
||||
domWarnings.push('- [ ] **Meta description missing** — search engines will auto-generate one, often poorly. Add a 155-char summary.');
|
||||
}
|
||||
if (domData.headings.skips.length > 0) {
|
||||
for (const skip of domData.headings.skips) {
|
||||
domWarnings.push(`- [ ] **Heading hierarchy skips ${skip}** — this confuses screen readers and weakens document structure.`);
|
||||
}
|
||||
}
|
||||
if (domData.images.missingAlt > 0) {
|
||||
domWarnings.push(`- [ ] **${domData.images.missingAlt} image(s) lack alt text** — WCAG 1.1.1 violation (Level A); screen readers will skip them entirely.`);
|
||||
}
|
||||
if (domData.links.externalNewTabNoOpener > 0) {
|
||||
domWarnings.push(`- [ ] **${domData.links.externalNewTabNoOpener} link(s) open in a new tab without \`rel="noopener"\`** — the opened page can access \`window.opener\`.`);
|
||||
}
|
||||
|
||||
// Critical section
|
||||
const criticalLines = critical.map(buildFindingLine);
|
||||
parts.push(
|
||||
`## Critical Issues (must fix)\n\n` +
|
||||
(criticalLines.length ? criticalLines.join('\n') : '_None detected._')
|
||||
);
|
||||
|
||||
// Warnings section
|
||||
const warningLines = [...warnings.map(buildFindingLine), ...domWarnings];
|
||||
parts.push(
|
||||
`## Warnings (should fix)\n\n` +
|
||||
(warningLines.length ? warningLines.join('\n') : '_None detected._')
|
||||
);
|
||||
|
||||
// Page Structure
|
||||
const h1s = domData.headings.h1;
|
||||
const h1Display = h1s.length === 0 ? 'none' : h1s.length === 1 ? `"${h1s[0]}"` : `${h1s.length} tags`;
|
||||
const headingCounts = ['h1','h2','h3','h4','h5','h6']
|
||||
.map((h) => `${h.toUpperCase()}×${domData.headings[h].length}`)
|
||||
.filter((s) => !s.endsWith('×0'))
|
||||
.join(', ') || 'none';
|
||||
|
||||
const sdTypes = domData.structuredData.length === 0
|
||||
? 'none'
|
||||
: domData.structuredData.map((s) => s?.['@type'] ?? 'unknown').join(', ');
|
||||
|
||||
const structureLines = [
|
||||
`- **Title:** ${domData.title ? `"${domData.title}" (${domData.titleLength} chars — ${titleAssessment(domData.titleLength)})` : 'missing'}`,
|
||||
`- **H1:** ${h1Display}`,
|
||||
`- **Meta description:** ${domData.meta.description ? `"${domData.meta.description.slice(0, 80)}${domData.meta.description.length > 80 ? '...' : ''}" (${domData.meta.descriptionLength} chars)` : 'missing'}`,
|
||||
`- **Canonical:** ${domData.canonical || 'not set'}`,
|
||||
`- **Heading hierarchy:** ${headingCounts}${domData.headings.skips.length ? ` ⚠ skips: ${domData.headings.skips.join(', ')}` : ''}`,
|
||||
`- **Images:** ${domData.images.total} total${domData.images.missingAlt ? `, ${domData.images.missingAlt} missing alt text` : ', all have alt text'}`,
|
||||
`- **Links:** ${domData.links.total} total, ${domData.links.external} external${domData.links.externalNewTabNoOpener ? `, ${domData.links.externalNewTabNoOpener} open new tab without noopener` : ''}`,
|
||||
`- **Structured data:** ${sdTypes}`,
|
||||
`- **Open Graph:** ${domData.openGraph.title ? 'present' : 'not set'}`,
|
||||
`- **Viewport meta:** ${domData.meta.viewport || 'not set'}`,
|
||||
];
|
||||
parts.push(`## Page Structure\n\n${structureLines.join('\n')}`);
|
||||
|
||||
// Screenshots
|
||||
parts.push(
|
||||
`## Screenshots\n\n` +
|
||||
`\n\n` +
|
||||
``
|
||||
);
|
||||
|
||||
// Raw data
|
||||
parts.push(
|
||||
`## Raw Data\n\n` +
|
||||
`- \`raw/lighthouse-desktop.json\` — full Lighthouse result (desktop)\n` +
|
||||
`- \`raw/lighthouse-mobile.json\` — full Lighthouse result (mobile)\n` +
|
||||
`- \`raw/dom.json\` — extracted DOM metadata`
|
||||
);
|
||||
|
||||
return parts.join('\n\n---\n\n');
|
||||
}
|
||||
127
src/screenshot.js
Normal file
127
src/screenshot.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const DESKTOP_VIEWPORT = { width: 1440, height: 900, deviceScaleFactor: 1 };
|
||||
const MOBILE_VIEWPORT = {
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScaleFactor: 2,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
};
|
||||
const MOBILE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
export async function captureScreenshots(browser, url, screenshotsDir) {
|
||||
// Desktop screenshot + DOM extraction in one page load
|
||||
const desktopPage = await browser.newPage();
|
||||
await desktopPage.setViewportSize(DESKTOP_VIEWPORT);
|
||||
await desktopPage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await desktopPage.screenshot({
|
||||
path: `${screenshotsDir}/desktop.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// Capture DOM data from the already-loaded desktop page (handed back to index.js)
|
||||
const rawDomSnapshot = await desktopPage.evaluate(extractDomSnapshot);
|
||||
await desktopPage.close();
|
||||
|
||||
// Mobile screenshot (separate page to avoid viewport bleed)
|
||||
const mobilePage = await browser.newPage();
|
||||
await mobilePage.setViewportSize(MOBILE_VIEWPORT);
|
||||
await mobilePage.setExtraHTTPHeaders({ 'user-agent': MOBILE_UA });
|
||||
await mobilePage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await mobilePage.screenshot({
|
||||
path: `${screenshotsDir}/mobile.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
await mobilePage.close();
|
||||
|
||||
return { domData: rawDomSnapshot };
|
||||
}
|
||||
|
||||
// Runs inside the browser context — must be serialisable
|
||||
function extractDomSnapshot() {
|
||||
const getMeta = (name) => {
|
||||
const el = document.querySelector(`meta[name="${name}"], meta[property="${name}"]`);
|
||||
return el?.content?.trim() || null;
|
||||
};
|
||||
|
||||
const title = document.title?.trim() || null;
|
||||
|
||||
const description = getMeta('description');
|
||||
const robots = getMeta('robots');
|
||||
const viewport = getMeta('viewport');
|
||||
const canonical = document.querySelector('link[rel="canonical"]')?.href?.trim() || null;
|
||||
|
||||
const openGraph = {
|
||||
title: getMeta('og:title'),
|
||||
description: getMeta('og:description'),
|
||||
image: getMeta('og:image'),
|
||||
type: getMeta('og:type'),
|
||||
};
|
||||
|
||||
// Headings
|
||||
const headingTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
|
||||
const headings = { h1: [], h2: [], h3: [], h4: [], h5: [], h6: [], skips: [] };
|
||||
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((el) => {
|
||||
const key = el.tagName.toLowerCase();
|
||||
headings[key].push(el.textContent?.trim().slice(0, 120) || '');
|
||||
});
|
||||
|
||||
// Detect heading level skips
|
||||
const allHeadings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
|
||||
for (let i = 1; i < allHeadings.length; i++) {
|
||||
const prev = parseInt(allHeadings[i - 1].tagName[1]);
|
||||
const curr = parseInt(allHeadings[i].tagName[1]);
|
||||
if (curr > prev + 1) {
|
||||
headings.skips.push(`H${prev}→H${curr}`);
|
||||
}
|
||||
}
|
||||
// Deduplicate skips
|
||||
headings.skips = [...new Set(headings.skips)];
|
||||
|
||||
// Images
|
||||
const allImages = Array.from(document.querySelectorAll('img'));
|
||||
const missingAltImgs = allImages.filter(
|
||||
(img) => !img.hasAttribute('alt') || img.alt.trim() === ''
|
||||
);
|
||||
const images = {
|
||||
total: allImages.length,
|
||||
missingAlt: missingAltImgs.length,
|
||||
missingAltSrcs: missingAltImgs.slice(0, 5).map((img) => (img.src || '').slice(0, 80)),
|
||||
};
|
||||
|
||||
// Links
|
||||
const allLinks = Array.from(document.querySelectorAll('a[href]'));
|
||||
const origin = window.location.origin;
|
||||
const externalLinks = allLinks.filter((a) => {
|
||||
try { return new URL(a.href).origin !== origin; } catch { return false; }
|
||||
});
|
||||
const externalNewTab = externalLinks.filter((a) => a.target === '_blank');
|
||||
const externalNewTabNoOpener = externalNewTab.filter(
|
||||
(a) => !(a.rel || '').split(/\s+/).includes('noopener')
|
||||
);
|
||||
const links = {
|
||||
total: allLinks.length,
|
||||
external: externalLinks.length,
|
||||
externalNewTab: externalNewTab.length,
|
||||
externalNewTabNoOpener: externalNewTabNoOpener.length,
|
||||
};
|
||||
|
||||
// Structured data
|
||||
const structuredData = [];
|
||||
document.querySelectorAll('script[type="application/ld+json"]').forEach((el) => {
|
||||
try { structuredData.push(JSON.parse(el.textContent)); }
|
||||
catch { structuredData.push(el.textContent?.trim()); }
|
||||
});
|
||||
|
||||
return {
|
||||
title,
|
||||
titleLength: title?.length ?? 0,
|
||||
meta: { description, descriptionLength: description?.length ?? null, robots, viewport },
|
||||
canonical,
|
||||
openGraph,
|
||||
headings,
|
||||
images,
|
||||
links,
|
||||
structuredData,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user