commit 8584def154cacde460bbf119e0c5fafe9215759b Author: Paul O'Reilly Date: Fri Apr 17 23:52:40 2026 +1200 M1: initial scaffold — pipeline, specs, source, Dockerfile Co-Authored-By: Claude Sonnet 4.6 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, + }; +}