From 8584def154cacde460bbf119e0c5fafe9215759b Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Fri, 17 Apr 2026 23:52:40 +1200 Subject: [PATCH] =?UTF-8?q?M1:=20initial=20scaffold=20=E2=80=94=20pipeline?= =?UTF-8?q?,=20specs,=20source,=20Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 7 ++ .gitignore | 3 + ABOUT.md | 1 + CLAUDE.md | 99 ++++++++++++++++++++++++ Dockerfile | 18 +++++ FUTURE.md | 59 ++++++++++++++ MEMORY.md | 6 ++ README.md | 71 +++++++++++++++++ SPEC.md | 9 +++ memory/project-status.md | 21 +++++ package.json | 19 +++++ scripts/verify-m1.sh | 70 +++++++++++++++++ spec/dom-analysis.md | 127 ++++++++++++++++++++++++++++++ spec/lighthouse.md | 123 +++++++++++++++++++++++++++++ spec/pipeline.md | 93 ++++++++++++++++++++++ spec/report.md | 130 +++++++++++++++++++++++++++++++ spec/screenshot.md | 77 ++++++++++++++++++ src/dom-analysis.js | 5 ++ src/index.js | 59 ++++++++++++++ src/lighthouse.js | 126 ++++++++++++++++++++++++++++++ src/report.js | 163 +++++++++++++++++++++++++++++++++++++++ src/screenshot.js | 127 ++++++++++++++++++++++++++++++ 22 files changed, 1413 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 ABOUT.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 FUTURE.md create mode 100644 MEMORY.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 memory/project-status.md create mode 100644 package.json create mode 100755 scripts/verify-m1.sh create mode 100644 spec/dom-analysis.md create mode 100644 spec/lighthouse.md create mode 100644 spec/pipeline.md create mode 100644 spec/report.md create mode 100644 spec/screenshot.md create mode 100644 src/dom-analysis.js create mode 100644 src/index.js create mode 100644 src/lighthouse.js create mode 100644 src/report.js create mode 100644 src/screenshot.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1211559 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +output/ +.git/ +*.md +spec/ +scripts/ +memory/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea5a9db --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +output/ +*.local diff --git a/ABOUT.md b/ABOUT.md new file mode 100644 index 0000000..d3fe01b --- /dev/null +++ b/ABOUT.md @@ -0,0 +1 @@ +description: Docker-based website audit tool producing Claude-readable Markdown reports diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fd4048f --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e87db8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/FUTURE.md b/FUTURE.md new file mode 100644 index 0000000..d299233 --- /dev/null +++ b/FUTURE.md @@ -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. diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..9c93cba --- /dev/null +++ b/MEMORY.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6bce45 --- /dev/null +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..90a0fea --- /dev/null +++ b/SPEC.md @@ -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 | diff --git a/memory/project-status.md b/memory/project-status.md new file mode 100644 index 0000000..bf5c522 --- /dev/null +++ b/memory/project-status.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..45da54e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/verify-m1.sh b/scripts/verify-m1.sh new file mode 100755 index 0000000..09aeff8 --- /dev/null +++ b/scripts/verify-m1.sh @@ -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 diff --git a/spec/dom-analysis.md b/spec/dom-analysis.md new file mode 100644 index 0000000..2883929 --- /dev/null +++ b/spec/dom-analysis.md @@ -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 `` 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 `` +**When:** DOM is analysed +**Then:** `structuredData` contains `[{ "@type": "WebSite" }]` diff --git a/spec/lighthouse.md b/spec/lighthouse.md new file mode 100644 index 0000000..4c8782f --- /dev/null +++ b/spec/lighthouse.md @@ -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` diff --git a/spec/pipeline.md b/spec/pipeline.md new file mode 100644 index 0000000..941520f --- /dev/null +++ b/spec/pipeline.md @@ -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 +ENV: TARGET_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 diff --git a/spec/report.md b/spec/report.md new file mode 100644 index 0000000..206f9ba --- /dev/null +++ b/spec/report.md @@ -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 +![Desktop view](screenshots/desktop.png) +![Mobile view](screenshots/mobile.png) +``` + +**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)` diff --git a/spec/screenshot.md b/spec/screenshot.md new file mode 100644 index 0000000..fb1e643 --- /dev/null +++ b/spec/screenshot.md @@ -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 diff --git a/src/dom-analysis.js b/src/dom-analysis.js new file mode 100644 index 0000000..a050b72 --- /dev/null +++ b/src/dom-analysis.js @@ -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; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7093acb --- /dev/null +++ b/src/index.js @@ -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 '); + console.error(' TARGET_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(); diff --git a/src/lighthouse.js b/src/lighthouse.js new file mode 100644 index 0000000..117f55d --- /dev/null +++ b/src/lighthouse.js @@ -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 }; diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..c272952 --- /dev/null +++ b/src/report.js @@ -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` + + `![Desktop view](screenshots/desktop.png)\n\n` + + `![Mobile view](screenshots/mobile.png)` + ); + + // 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'); +} diff --git a/src/screenshot.js b/src/screenshot.js new file mode 100644 index 0000000..6f873cb --- /dev/null +++ b/src/screenshot.js @@ -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, + }; +}