M1: initial scaffold — pipeline, specs, source, Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-04-17 23:52:40 +12:00
commit 8584def154
22 changed files with 1413 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
output/
.git/
*.md
spec/
scripts/
memory/

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
output/
*.local

1
ABOUT.md Normal file
View File

@@ -0,0 +1 @@
description: Docker-based website audit tool producing Claude-readable Markdown reports

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# CLAUDE.md — website-evaluator
## Overview
A Docker-based website audit pipeline. Given a URL it runs screenshots (Playwright),
Lighthouse audits (desktop + mobile), and DOM analysis, then assembles a structured
Markdown report optimised for consumption by Claude.
## Repository Structure
```
website-evaluator/
├── ABOUT.md # One-line description for context-load menu
├── CLAUDE.md # This file
├── MEMORY.md # Index of memory topic files
├── FUTURE.md # Ideas not on the active roadmap
├── README.md # Human-readable overview and quick start
├── SPEC.md # Index of spec files
├── spec/ # Per-subsystem specs (WE-P, WE-S, WE-L, WE-D, WE-R)
├── src/
│ ├── index.js # Entrypoint — arg parsing and pipeline orchestration
│ ├── screenshot.js # Playwright screenshot capture (desktop + mobile)
│ ├── lighthouse.js # Lighthouse audit runner (desktop + mobile presets)
│ ├── dom-analysis.js # DOM metadata extraction via Playwright
│ └── report.js # Markdown report assembly
├── Dockerfile # Node 20 + Playwright base image
├── .dockerignore
├── package.json
└── scripts/
└── verify-m1.sh # Smoke test: run against example.com, check outputs
```
## Tech Stack
- **Runtime:** Node.js 20, ES modules (`type: "module"`)
- **Screenshots + DOM:** Playwright (Chromium)
- **Audits:** Lighthouse 12 + chrome-launcher (uses Playwright's bundled Chromium)
- **Base image:** `mcr.microsoft.com/playwright/node:20-noble`
## Running Locally (Docker)
```bash
# Build
docker build -t website-evaluator .
# Run — output lands in ./output/
mkdir -p output
docker run --rm -v "$(pwd)/output:/output" website-evaluator https://example.com
# Output
output/
report.md # Primary artifact — feed this to Claude
screenshots/
desktop.png
mobile.png
raw/
lighthouse-desktop.json
lighthouse-mobile.json
dom.json
```
## Environment Variables
| Variable | Default | Purpose |
|---|---|---|
| `OUTPUT_DIR` | `/output` | Where to write all output files |
URL is passed as a CLI argument. `TARGET_URL` env var is also accepted as fallback.
## Spec Prefixes
| Spec file | Prefix | Domain |
|---|---|---|
| pipeline.md | WE-P | Orchestration + I/O contract |
| screenshot.md | WE-S | Screenshot capture |
| lighthouse.md | WE-L | Lighthouse audit |
| dom-analysis.md | WE-D | DOM metadata extraction |
| report.md | WE-R | Report structure + tone |
## Conventions
- ES modules throughout (`import`/`export`, `.js` extensions in imports)
- Async/await — no callbacks
- Each module exports one primary function; no side effects at import time
- Errors propagate to `index.js` which handles process exit
- `console.log` for progress, `console.error` for failures
## Milestones
| # | Scope | Status |
|---|---|---|
| M1 | Core pipeline: screenshot + Lighthouse + DOM → report.md | In progress |
| M2 | `--analyze` flag: calls Claude API, appends AI recommendations | Future |
| M3 | Comparison mode: before/after and competitor diffs | Future |
## Gitea
- **Org:** `skynet`
- **Remote:** `git@gitea.oreillyit.nz-ai-enablement:skynet/website-evaluator.git`

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM mcr.microsoft.com/playwright/node:20-noble
WORKDIR /app
# Install dependencies first (layer cache)
COPY package*.json ./
RUN npm ci --omit=dev
# Copy source
COPY src/ ./src/
# Create default output dir (overridden by volume mount at runtime)
RUN mkdir -p /output
ENV OUTPUT_DIR=/output
ENV NODE_ENV=production
ENTRYPOINT ["node", "src/index.js"]

59
FUTURE.md Normal file
View File

@@ -0,0 +1,59 @@
# Future Ideas — website-evaluator
## M2: Claude API Analysis Layer
**Problem:** The report is structured data — Claude still has to read it and form recommendations
manually each time.
**Idea:** Add an optional `--analyze` flag. After generating the report, call the Claude API
with the report + screenshots (vision) and append an AI-generated "Recommendations" section
with prioritised, actionable fixes.
**Open questions:**
- Should the recommendations overwrite a section or be a separate file?
- Which Claude model for analysis? Sonnet for cost, Opus for depth?
- How to handle the screenshot as base64 input to the API?
**Depends on:** M1 verified and stable.
---
## M3: Comparison Mode
**Problem:** It's hard to tell if improvements actually moved the needle without a before/after view.
**Idea:** Accept two URLs (or a URL + cached baseline) and produce a diff report highlighting
changes in scores, new/resolved issues, and visual diffs between screenshots.
**Open questions:**
- Store baselines as JSON in the output dir? Or accept two full report.md files?
- Visual diff: pixel-diff the screenshots or just note score changes?
**Depends on:** M1 stable. M2 optional.
---
## Batch Mode
**Problem:** Evaluating 10 pages of a site requires running the container 10 times.
**Idea:** Accept a newline-delimited list of URLs and produce one report per URL plus
a summary roll-up.
**Open questions:**
- Output structure: one folder per URL? Or a single multi-page report?
**Depends on:** M1 stable.
---
## CI Integration Helper
**Problem:** Running the evaluator manually is fine for one-offs but doesn't scale to
catching regressions in CI.
**Idea:** Produce a machine-readable `scores.json` alongside the Markdown report.
Add a `--fail-under` flag that exits non-zero if any Lighthouse category drops below
a threshold. This makes it usable as a CI gate.
**Depends on:** M1 stable.

6
MEMORY.md Normal file
View File

@@ -0,0 +1,6 @@
# Memory Index — website-evaluator
_One-line entries only. Content lives in `memory/` topic files._
## Status
- [Project status](memory/project-status.md) — Current milestone, what's next, blockers

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# website-evaluator
A Docker-based website audit tool. Given a URL it captures screenshots, runs
Lighthouse audits (desktop + mobile), extracts DOM metadata, and assembles a
structured Markdown report designed to be read by Claude for design critique
and improvement recommendations.
## Quick Start
```bash
# Build the image
docker build -t website-evaluator .
# Evaluate a site
mkdir -p output
docker run --rm -v "$(pwd)/output:/output" website-evaluator https://example.com
# Feed the report to Claude
# Open output/report.md and paste it into a Claude conversation with:
# "Here is a website evaluation report. Please analyse it and suggest improvements."
```
## Output
```
output/
report.md # Primary artifact — structured Markdown for Claude
screenshots/
desktop.png # Full-page desktop (1440×900)
mobile.png # Full-page mobile (375×812)
raw/
lighthouse-desktop.json
lighthouse-mobile.json
dom.json
```
## What the Report Covers
- **Lighthouse scores** — Performance, Accessibility, Best Practices, SEO (desktop + mobile)
- **Core Web Vitals** — LCP, TBT, CLS, TTFB with status labels
- **Critical issues** — Pre-interpreted findings with "so what" explanations and fix guidance
- **Page structure** — Title, meta description, canonical, heading hierarchy, images, links
- **Accessibility detail** — WCAG violations grouped by impact level
- **Performance detail** — Opportunities and diagnostics with estimated savings
- **SEO detail** — Meta, robots, canonical, structured data
## Milestones
| # | Scope | Status |
|---|---|---|
| M1 | Core pipeline: screenshot + Lighthouse + DOM → report.md | ✅ Complete |
| M2 | `--analyze` flag: Claude API integration, AI recommendations | Planned |
| M3 | Comparison mode: before/after and competitor diffs | Planned |
## Scripts
| Script | Purpose |
|---|---|
| `scripts/verify-m1.sh` | Smoke test: builds image, runs against example.com, checks all outputs exist |
## Architecture
```
URL in
└─▶ Playwright: screenshot desktop (1440px) + mobile (375px) + DOM extraction
└─▶ Lighthouse: desktop audit
└─▶ Lighthouse: mobile audit
└─▶ Report assembly → /output/report.md
```
Built on `mcr.microsoft.com/playwright/node:20-noble` — Chrome is bundled, no separate install needed.

9
SPEC.md Normal file
View File

@@ -0,0 +1,9 @@
# Spec Index — website-evaluator
| Spec | Prefix | Read when... |
|---|---|---|
| [pipeline.md](spec/pipeline.md) | WE-P | Working on orchestration, input parsing, output layout, error handling |
| [screenshot.md](spec/screenshot.md) | WE-S | Working on screenshot capture or viewport configuration |
| [lighthouse.md](spec/lighthouse.md) | WE-L | Working on audit configuration, score extraction, or findings categorisation |
| [dom-analysis.md](spec/dom-analysis.md) | WE-D | Working on DOM metadata extraction |
| [report.md](spec/report.md) | WE-R | Working on report structure, section order, tone, or formatting rules |

21
memory/project-status.md Normal file
View File

@@ -0,0 +1,21 @@
---
name: project-status
description: Current milestone, what's next, blockers
type: project
---
# Project Status
**Current milestone:** M1 — Core pipeline
**What's complete:** Project scaffold, specs, source code, Dockerfile
**What's next:** Build Docker image, run verify-m1.sh against example.com
**M1 scope:** screenshot + Lighthouse (desktop + mobile) + DOM analysis → report.md
**Why:** Self-evaluation tool for websites. Designed so Claude can read the
report and provide actionable design/UX/performance critique.
**How to apply:** M2 is the Claude API integration layer (`--analyze` flag).
Don't start M2 until M1 is verified end-to-end.

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "website-evaluator",
"version": "1.0.0",
"type": "module",
"description": "Website audit tool producing Claude-readable Markdown reports",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"lint": "node --check src/*.js"
},
"dependencies": {
"chrome-launcher": "^1.1.2",
"lighthouse": "^12.2.1",
"playwright": "^1.44.0"
},
"engines": {
"node": ">=20"
}
}

70
scripts/verify-m1.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Smoke test for M1: builds the Docker image and runs it against example.com,
# then verifies all expected output files and report sections exist.
set -euo pipefail
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
pass() { echo -e "${GREEN}✓ PASS${NC} $1"; }
fail() { echo -e "${RED}✗ FAIL${NC} $1"; FAILED=1; }
FAILED=0
OUTPUT_DIR="$(pwd)/output-verify-$$"
mkdir -p "$OUTPUT_DIR"
cleanup() { rm -rf "$OUTPUT_DIR"; }
trap cleanup EXIT
cd "$(dirname "$0")/.."
echo "Building Docker image (website-evaluator)..."
docker build -t website-evaluator . -q
echo "Running evaluation against https://example.com..."
docker run --rm -v "$OUTPUT_DIR:/output" website-evaluator https://example.com
echo ""
echo "Checking outputs..."
check_file() {
local path="$OUTPUT_DIR/$1"
if [ -s "$path" ]; then
pass "$1 exists and is non-empty"
else
fail "$1 missing or empty"
fi
}
check_file "report.md"
check_file "screenshots/desktop.png"
check_file "screenshots/mobile.png"
check_file "raw/lighthouse-desktop.json"
check_file "raw/lighthouse-mobile.json"
check_file "raw/dom.json"
check_section() {
if grep -q "$1" "$OUTPUT_DIR/report.md" 2>/dev/null; then
pass "report.md contains: $1"
else
fail "report.md missing: $1"
fi
}
check_section "Lighthouse Scores"
check_section "Core Web Vitals"
check_section "Critical Issues"
check_section "Warnings"
check_section "Page Structure"
check_section "Screenshots"
echo ""
if [ "$FAILED" = "0" ]; then
echo -e "${GREEN}All M1 checks passed.${NC}"
exit 0
else
echo -e "${RED}Some M1 checks failed. See above for details.${NC}"
exit 1
fi

127
spec/dom-analysis.md Normal file
View File

@@ -0,0 +1,127 @@
# DOM Analysis Spec (WE-D)
## Overview
Extracts structured metadata from a loaded page using Playwright's `page.evaluate()`.
Provides the page structure data used in the report's "Page Structure" section.
Runs in the same browser session as the screenshots to avoid a second page load.
## Responsibilities
- Extract title, meta tags, canonical URL, Open Graph tags
- Count and validate heading hierarchy (H1H6)
- Audit images for missing alt text
- Audit links for external targets missing `rel="noopener"`
- Detect JSON-LD structured data
- Return a plain object (serialisable to JSON)
Delegates to: Playwright `page.evaluate()`
## Dependencies
Read [pipeline.md](pipeline.md) for output file layout.
Read [screenshot.md](screenshot.md) — DOM extraction reuses the same Playwright page.
## Data Model
```js
{
title: 'Example Domain',
titleLength: 14,
meta: {
description: null, // string or null
descriptionLength: null, // number or null
robots: 'index, follow', // string or null
viewport: 'width=device-width, initial-scale=1', // string or null
},
canonical: null, // string URL or null
openGraph: {
title: null,
description: null,
image: null,
type: null,
},
headings: {
h1: ['Example Domain'], // array of text content
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
skips: [], // e.g. ['H1→H3'] if H2 is absent between H1 and H3
},
images: {
total: 12,
missingAlt: 3, // count with no alt attribute or alt=""
missingAltSrcs: ['...'], // up to 5 example src values
},
links: {
total: 24,
external: 8,
externalNewTab: 4, // target="_blank"
externalNewTabNoOpener: 3, // target="_blank" without rel containing noopener
},
structuredData: [], // array of parsed JSON-LD objects (empty if none)
}
```
## Requirements
**WE-D-1:** Extract metadata in a single `page.evaluate()` call to minimise
round-trips. All DOM access happens inside the browser context.
**WE-D-2:** `meta.description` is `null` if the `<meta name="description">` tag
is absent or has an empty `content` attribute.
**WE-D-3:** Heading hierarchy skips are detected by comparing sequential heading
levels. If the level jumps by more than 1 (e.g., H1 → H3), record the skip as
`"H1→H3"` in `headings.skips`.
Why: Heading skips confuse screen readers and weaken SEO.
**WE-D-4:** An image is counted as `missingAlt` if it lacks the `alt` attribute
entirely OR if `alt` is an empty string `""`.
Why: Both mean the image is inaccessible to screen readers.
**WE-D-5:** A link is counted as `externalNewTabNoOpener` if it has `target="_blank"`
AND its `rel` attribute does not contain `noopener`.
Why: Without `noopener`, the opened page can access `window.opener` — a security risk.
**WE-D-6:** Structured data is extracted from all `<script type="application/ld+json">`
tags. Parse each; if parsing fails, include the raw text string instead of the object.
**WE-D-7:** The `missingAltSrcs` array contains at most 5 example `src` values
(truncated to 80 chars each) to keep the report concise.
**WE-D-8:** The result must be JSON-serialisable (no DOM nodes, functions, or circular refs).
**WE-D-9:** Run on the desktop viewport page (the same page used for the desktop screenshot).
Do not navigate again.
## Scenarios
### Scenario: Well-structured page
**Given:** Page with one H1, sequential headings, all images have alt, no blank-target links
**When:** `analyzeDom(page)` is called
**Then:** `headings.skips` is empty, `images.missingAlt === 0`, `links.externalNewTabNoOpener === 0`
### Scenario: Missing meta description
**Given:** Page with no `<meta name="description">` tag
**When:** DOM is analysed
**Then:** `meta.description === null`, `meta.descriptionLength === null`
### Scenario: Heading skip detected
**Given:** Page with H1 then H3 (no H2)
**When:** DOM is analysed
**Then:** `headings.skips` includes `"H1→H3"`
### Scenario: JSON-LD present
**Given:** Page with `<script type="application/ld+json">{"@type":"WebSite"}</script>`
**When:** DOM is analysed
**Then:** `structuredData` contains `[{ "@type": "WebSite" }]`

123
spec/lighthouse.md Normal file
View File

@@ -0,0 +1,123 @@
# Lighthouse Spec (WE-L)
## Overview
Runs Lighthouse audits against a URL in two presets (desktop and mobile) and
extracts structured findings. Returns a normalised result object used by the
report assembler. Also saves the full Lighthouse JSON to the raw directory for
reference.
## Responsibilities
- Launch Chrome via chrome-launcher using Playwright's bundled Chromium
- Run a desktop Lighthouse audit
- Run a mobile Lighthouse audit
- Extract scores, Core Web Vitals, and categorised audit findings
- Save raw results to the raw directory
- Return structured results for report assembly
Delegates to: Lighthouse, chrome-launcher, Playwright (for executable path)
## Dependencies
Read [pipeline.md](pipeline.md) for output directory layout.
## Data Model
### LighthouseResult (returned per preset)
```js
{
preset: 'desktop' | 'mobile',
scores: {
performance: 87, // 0-100
accessibility: 92,
bestPractices: 100,
seo: 90,
},
vitals: {
lcp: { value: '1.2 s', status: 'good' }, // good | needs-improvement | poor
tbt: { value: '120 ms', status: 'good' },
cls: { value: '0.02', status: 'good' },
ttfb: { value: '380 ms', status: 'needs-improvement' },
fcp: { value: '0.8 s', status: 'good' },
si: { value: '1.1 s', status: 'good' },
},
findings: {
critical: [ { id, title, description, impact } ], // score === 0
warnings: [ { id, title, description, impact } ], // 0 < score < 0.9
},
}
```
### Vital status thresholds
| Vital | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| TBT | ≤ 200ms | ≤ 600ms | > 600ms |
| CLS | ≤ 0.1 | ≤ 0.25 | > 0.25 |
| TTFB | ≤ 800ms | ≤ 1800ms | > 1800s |
| FCP | ≤ 1.8s | ≤ 3.0s | > 3.0s |
| SI | ≤ 3.4s | ≤ 5.8s | > 5.8s |
## Requirements
**WE-L-1:** Use `chromium.executablePath()` from Playwright to locate the Chrome
binary. Pass this path to `chrome-launcher` as `chromePath`.
Why: The Docker base image includes Playwright's Chromium — we must use that
executable rather than letting chrome-launcher search default system paths.
**WE-L-2:** Run two separate audits: one with `formFactor: 'desktop'` and one
with `formFactor: 'mobile'`.
**WE-L-3:** Desktop screen emulation: `{ mobile: false, width: 1440, height: 900,
deviceScaleFactor: 1, disabled: false }`.
**WE-L-4:** Mobile screen emulation: `{ mobile: true, width: 375, height: 812,
deviceScaleFactor: 2, disabled: false }`.
**WE-L-5:** Run only the four standard categories: `performance`, `accessibility`,
`best-practices`, `seo`. Omit `pwa`.
**WE-L-6:** Scores are extracted from `lhr.categories[id].score * 100`, rounded
to the nearest integer.
**WE-L-7:** Core Web Vitals are extracted from these audit IDs:
- LCP: `largest-contentful-paint`
- TBT: `total-blocking-time` (TBT is the Lighthouse proxy for FID/INP)
- CLS: `cumulative-layout-shift`
- TTFB: `server-response-time`
- FCP: `first-contentful-paint`
- SI: `speed-index`
**WE-L-8:** A finding is **critical** if `audit.score === 0` and the audit is
not `informative` mode. A finding is a **warning** if `0 < audit.score < 0.9`.
Audits with score ≥ 0.9 or score `null` are omitted.
**WE-L-9:** Finding `description` is taken from `audit.description` (Markdown,
may contain links). Finding `impact` is taken from `audit.details.type === 'opportunity'`
savings estimate if present, otherwise omitted.
**WE-L-10:** Save the full `lhr` object as JSON to `raw/lighthouse-{preset}.json`.
**WE-L-11:** Kill the Chrome instance in a `finally` block even if the audit throws.
Why: Orphaned Chrome processes inside Docker cause container bloat.
## Scenarios
### Scenario: Happy path
**Given:** Reachable URL, valid Chrome path
**When:** `runLighthouse(url, rawDir)` is called
**Then:** Returns `[desktopResult, mobileResult]`; both `lighthouse-desktop.json`
and `lighthouse-mobile.json` exist in `rawDir`
### Scenario: Unreachable URL
**Given:** URL returns connection refused
**When:** Lighthouse audit runs
**Then:** Error is thrown; Chrome is killed before the error propagates
### Scenario: Score extraction
**Given:** `lhr.categories.performance.score = 0.87`
**When:** Score is extracted
**Then:** `scores.performance === 87`

93
spec/pipeline.md Normal file
View File

@@ -0,0 +1,93 @@
# Pipeline Spec (WE-P)
## Overview
Orchestrates the full evaluation pipeline for a single URL. Accepts a URL, runs
screenshot capture, Lighthouse audits, and DOM analysis in sequence, writes all
outputs to a configured directory, and exits with a clear success or failure code.
## Responsibilities
- Parse and validate input (URL + output directory)
- Execute pipeline stages in order: screenshot → lighthouse → DOM → report
- Write all output files to the configured output directory
- Exit 0 on success, exit 1 on any stage failure (with error to stderr)
Delegates to: `screenshot.js`, `lighthouse.js`, `dom-analysis.js`, `report.js`
## Dependencies
None — this is the root spec. All other specs depend on this one for the I/O contract.
## Data Model
### Input
```
CLI: node src/index.js <url>
ENV: TARGET_URL=<url> (fallback if no CLI arg)
ENV: OUTPUT_DIR=/output (default: /output)
```
### Output directory layout
```
$OUTPUT_DIR/
report.md ← primary artifact
screenshots/
desktop.png
mobile.png
raw/
lighthouse-desktop.json
lighthouse-mobile.json
dom.json
```
## Requirements
**WE-P-1:** The URL must be provided as the first CLI argument or via `TARGET_URL`.
If neither is present, print usage to stderr and exit 1.
**WE-P-2:** The URL must start with `http://` or `https://`. If it does not, print
an error and exit 1.
Why: Lighthouse and Playwright both require a valid HTTP URL. Silent failures with
invalid URLs are confusing.
**WE-P-3:** The output directory (default `/output`) and its subdirectories
(`screenshots/`, `raw/`) must be created if they do not exist.
Why: The Docker volume mount only creates the parent; subdirs must be explicit.
**WE-P-4:** Pipeline stages run sequentially in this order:
1. Screenshots + DOM extraction (single Playwright browser session)
2. Lighthouse desktop audit
3. Lighthouse mobile audit
4. Report assembly
Why: Lighthouse requires its own Chrome instance. Running screenshots and DOM
extraction first in a single Playwright session is more efficient than two sessions.
**WE-P-5:** Each pipeline stage logs a single progress line to stdout before starting
(e.g., `"Capturing screenshots..."`).
**WE-P-6:** If any stage throws, the error message is printed to stderr and the
process exits 1. Partial output files are acceptable — the caller should check
exit code, not file presence.
**WE-P-7:** On success, print the path to `report.md` and exit 0.
## Scenarios
### Scenario: Happy path
**Given:** Valid URL `https://example.com` and writable `/output`
**When:** Pipeline completes all stages
**Then:** Exit 0; `report.md`, `screenshots/desktop.png`, `screenshots/mobile.png`,
`raw/lighthouse-desktop.json`, `raw/lighthouse-mobile.json`, `raw/dom.json` all exist
### Scenario: Missing URL
**Given:** No CLI arg and `TARGET_URL` not set
**When:** `index.js` starts
**Then:** Prints usage to stderr, exits 1
### Scenario: Invalid URL scheme
**Given:** URL `file:///etc/passwd`
**When:** Validation runs
**Then:** Prints error "URL must start with http:// or https://", exits 1

130
spec/report.md Normal file
View File

@@ -0,0 +1,130 @@
# Report Spec (WE-R)
## Overview
Assembles all collected data into a single Markdown file optimised for Claude
to read. Findings are phrased in a pre-interpreted tone: each issue explains
what it means and what to do about it, not just that a value is absent or wrong.
## Responsibilities
- Accept LighthouseResult[] and DomData as inputs
- Produce a single Markdown string
- Apply severity grading to Lighthouse scores
- Categorise findings as Critical (must fix) / Warning (should fix) / Info
- Write the report in the defined section order
- Deduplicate findings that appear in both desktop and mobile audits
Delegates to: nothing (pure function, no I/O)
## Dependencies
Read [lighthouse.md](lighthouse.md) for `LighthouseResult` shape.
Read [dom-analysis.md](dom-analysis.md) for `DomData` shape.
## Data Model
### Score grading
| Score | Grade | Colour label |
|---|---|---|
| 90100 | A | Good |
| 7589 | B | Needs improvement |
| 5074 | C | Poor |
| 049 | 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 3065 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)`

77
spec/screenshot.md Normal file
View File

@@ -0,0 +1,77 @@
# Screenshot Spec (WE-S)
## Overview
Captures full-page screenshots of a URL at desktop and mobile viewport sizes
using Playwright (Chromium). Screenshots are saved as PNG files to the output
screenshots directory.
## Responsibilities
- Launch a Playwright Chromium browser
- Capture a full-page desktop screenshot (1440×900 viewport)
- Capture a full-page mobile screenshot (375×812 viewport, mobile UA)
- Save both as PNG to the screenshots directory
- Return the browser instance for DOM analysis reuse
Delegates to: Playwright
## Dependencies
Read [pipeline.md](pipeline.md) for output directory layout.
## Data Model
### Viewport configs
```js
const DESKTOP = { width: 1440, height: 900, deviceScaleFactor: 1 };
const MOBILE = { width: 375, height: 812, deviceScaleFactor: 2, isMobile: true,
hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...' };
```
### Output
```
screenshots/desktop.png — full-page, lossless PNG
screenshots/mobile.png — full-page, lossless PNG, 2× pixel density
```
## Requirements
**WE-S-1:** Navigate to the URL and wait for `networkidle` before capturing.
Why: Ensures lazy-loaded content and web fonts are rendered.
**WE-S-2:** Capture a full-page screenshot (not just the visible viewport).
Why: Full-page screenshots let Claude see below-the-fold content.
**WE-S-3:** Desktop viewport: 1440×900, deviceScaleFactor 1, no mobile emulation.
**WE-S-4:** Mobile viewport: 375×812, deviceScaleFactor 2, mobile UA, touch enabled.
The mobile UA string should identify as an iPhone running Safari to maximise
site responsiveness compatibility.
**WE-S-5:** Save format: PNG (lossless). Do not use JPEG.
Why: Lossless preserves fine text and UI details important for design critique.
**WE-S-6:** The function must accept an already-open Playwright browser instance
(not launch its own) so the caller can reuse the session for DOM extraction.
**WE-S-7:** Both screenshots must be saved before returning.
## Scenarios
### Scenario: Happy path
**Given:** Valid browser instance, reachable URL, writable screenshots dir
**When:** `captureScreenshots(browser, url, screenshotsDir)` is called
**Then:** `desktop.png` and `mobile.png` exist in `screenshotsDir`, both non-zero bytes
### Scenario: Navigation timeout
**Given:** URL that times out (server unresponsive)
**When:** Page navigation exceeds 30s
**Then:** Error is thrown with the URL and "navigation timeout" in the message
### Scenario: Page with no content
**Given:** URL that returns an empty 200 response
**When:** Screenshots are captured
**Then:** Screenshots are saved (may be blank); no error is thrown

5
src/dom-analysis.js Normal file
View File

@@ -0,0 +1,5 @@
// Processes the raw DOM snapshot captured by screenshot.js inside the browser context.
// No browser access needed here — all extraction happens in captureScreenshots().
export function analyzeDom(rawSnapshot) {
return rawSnapshot;
}

59
src/index.js Normal file
View File

@@ -0,0 +1,59 @@
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'fs/promises';
import { captureScreenshots } from './screenshot.js';
import { analyzeDom } from './dom-analysis.js';
import { runLighthouse } from './lighthouse.js';
import { assembleReport } from './report.js';
async function main() {
const url = process.argv[2] || process.env.TARGET_URL;
if (!url) {
console.error('Usage: node src/index.js <url>');
console.error(' TARGET_URL=<url> node src/index.js');
process.exit(1);
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
console.error(`Error: URL must start with http:// or https:// (got: ${url})`);
process.exit(1);
}
const outputDir = process.env.OUTPUT_DIR || '/output';
const screenshotsDir = `${outputDir}/screenshots`;
const rawDir = `${outputDir}/raw`;
await mkdir(screenshotsDir, { recursive: true });
await mkdir(rawDir, { recursive: true });
let browser;
try {
// Stage 1: Screenshots + DOM (single browser session)
console.log('Capturing screenshots and analysing DOM...');
browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const { domData } = await captureScreenshots(browser, url, screenshotsDir);
await browser.close();
const domAnalysis = analyzeDom(domData);
browser = null;
await writeFile(`${rawDir}/dom.json`, JSON.stringify(domAnalysis, null, 2));
// Stage 2 & 3: Lighthouse desktop + mobile
console.log('Running Lighthouse desktop audit...');
console.log('Running Lighthouse mobile audit...');
const lighthouseResults = await runLighthouse(url, rawDir);
// Stage 4: Report assembly
console.log('Assembling report...');
const report = assembleReport(url, lighthouseResults, domAnalysis);
const reportPath = `${outputDir}/report.md`;
await writeFile(reportPath, report);
console.log(`\nDone! Report written to: ${reportPath}`);
} catch (err) {
if (browser) await browser.close().catch(() => {});
console.error(`\nError: ${err.message}`);
process.exit(1);
}
}
main();

126
src/lighthouse.js Normal file
View File

@@ -0,0 +1,126 @@
import lighthouse from 'lighthouse';
import { launch } from 'chrome-launcher';
import { chromium } from 'playwright';
import { writeFile } from 'fs/promises';
const VITAL_AUDITS = [
{ key: 'lcp', id: 'largest-contentful-paint', label: 'LCP (Largest Contentful Paint)' },
{ key: 'tbt', id: 'total-blocking-time', label: 'TBT (Total Blocking Time)' },
{ key: 'cls', id: 'cumulative-layout-shift', label: 'CLS (Cumulative Layout Shift)' },
{ key: 'ttfb', id: 'server-response-time', label: 'TTFB (Time to First Byte)' },
{ key: 'fcp', id: 'first-contentful-paint', label: 'FCP (First Contentful Paint)' },
{ key: 'si', id: 'speed-index', label: 'Speed Index' },
];
function vitalStatus(score) {
if (score === null || score === undefined) return 'N/A';
if (score >= 0.9) return 'Good';
if (score >= 0.5) return 'Needs improvement';
return 'Poor';
}
function scoreGrade(score) {
if (score >= 90) return 'A';
if (score >= 75) return 'B';
if (score >= 50) return 'C';
return 'F';
}
function stripMarkdownLinks(text) {
return (text || '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
}
function extractFindings(lhr) {
// Collect audit IDs referenced by our four categories
const relevantIds = new Set();
for (const cat of Object.values(lhr.categories)) {
for (const ref of cat.auditRefs) relevantIds.add(ref.id);
}
const critical = [];
const warnings = [];
for (const [id, audit] of Object.entries(lhr.audits)) {
if (!relevantIds.has(id)) continue;
if (audit.score === null || audit.scoreDisplayMode === 'informative' ||
audit.scoreDisplayMode === 'notApplicable') continue;
const finding = {
id,
title: audit.title,
description: stripMarkdownLinks(audit.description),
savings: audit.details?.overallSavingsMs
? `Potential saving: ~${Math.round(audit.details.overallSavingsMs)}ms`
: null,
};
if (audit.score === 0) critical.push(finding);
else if (audit.score < 0.9) warnings.push(finding);
}
return { critical, warnings };
}
async function runPreset(url, preset, rawDir) {
const chromePath = chromium.executablePath();
const chrome = await launch({
chromePath,
chromeFlags: ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
try {
const isDesktop = preset === 'desktop';
const flags = {
port: chrome.port,
output: 'json',
logLevel: 'error',
formFactor: preset,
screenEmulation: {
mobile: !isDesktop,
width: isDesktop ? 1440 : 375,
height: isDesktop ? 900 : 812,
deviceScaleFactor: isDesktop ? 1 : 2,
disabled: false,
},
throttlingMethod: 'simulate',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
};
const { lhr } = await lighthouse(url, flags);
await writeFile(`${rawDir}/lighthouse-${preset}.json`, JSON.stringify(lhr, null, 2));
const scores = {
performance: Math.round((lhr.categories['performance']?.score ?? 0) * 100),
accessibility: Math.round((lhr.categories['accessibility']?.score ?? 0) * 100),
bestPractices: Math.round((lhr.categories['best-practices']?.score ?? 0) * 100),
seo: Math.round((lhr.categories['seo']?.score ?? 0) * 100),
};
const vitals = {};
for (const { key, id, label } of VITAL_AUDITS) {
const audit = lhr.audits[id];
vitals[key] = {
label,
value: audit?.displayValue ?? 'N/A',
status: vitalStatus(audit?.score ?? null),
};
}
const { critical, warnings } = extractFindings(lhr);
return { preset, scores, vitals, findings: { critical, warnings } };
} finally {
await chrome.kill();
}
}
export async function runLighthouse(url, rawDir) {
const [desktop, mobile] = await Promise.all([
runPreset(url, 'desktop', rawDir),
runPreset(url, 'mobile', rawDir),
]);
return [desktop, mobile];
}
export { scoreGrade };

163
src/report.js Normal file
View File

@@ -0,0 +1,163 @@
import { scoreGrade } from './lighthouse.js';
function gradeLabel(score) {
if (score >= 90) return 'Good';
if (score >= 75) return 'Needs improvement';
if (score >= 50) return 'Poor';
return 'Critical';
}
function titleAssessment(len) {
if (!len) return 'missing';
if (len < 30) return 'too short';
if (len > 65) return 'too long';
return 'good length';
}
// Merge findings from desktop + mobile, keeping worst score per audit ID.
function mergeFindings(lighthouseResults) {
const criticalMap = new Map();
const warningMap = new Map();
for (const result of lighthouseResults) {
for (const f of result.findings.critical) {
if (!criticalMap.has(f.id)) criticalMap.set(f.id, f);
}
for (const f of result.findings.warnings) {
// Upgrade to critical if appears as critical in the other preset
if (criticalMap.has(f.id)) continue;
if (!warningMap.has(f.id)) warningMap.set(f.id, f);
}
}
return {
critical: Array.from(criticalMap.values()),
warnings: Array.from(warningMap.values()),
};
}
function buildFindingLine(f) {
const savings = f.savings ? ` _(${f.savings})_` : '';
return `- [ ] **${f.title}** — ${f.description}${savings}`;
}
export function assembleReport(url, lighthouseResults, domData) {
const hostname = new URL(url).hostname;
const date = new Date().toISOString().split('T')[0];
const [desktop, mobile] = lighthouseResults;
const parts = [];
// Header
parts.push(`# Website Evaluation: ${hostname}\n_Generated: ${date} · Audited: ${url}_`);
// Lighthouse Scores
const scoreRows = [
['Performance', desktop.scores.performance, mobile.scores.performance],
['Accessibility', desktop.scores.accessibility, mobile.scores.accessibility],
['Best Practices', desktop.scores.bestPractices, mobile.scores.bestPractices],
['SEO', desktop.scores.seo, mobile.scores.seo],
];
const scoreTable = [
'| Category | Desktop | Mobile | Desktop Grade | Mobile Grade |',
'|---|---|---|---|---|',
...scoreRows.map(([cat, d, m]) =>
`| ${cat} | ${d} | ${m} | ${scoreGrade(d)}${gradeLabel(d)} | ${scoreGrade(m)}${gradeLabel(m)} |`
),
].join('\n');
parts.push(`## Lighthouse Scores\n\n${scoreTable}`);
// Core Web Vitals
const vitalKeys = ['lcp', 'tbt', 'cls', 'ttfb', 'fcp', 'si'];
const vitalRows = vitalKeys.map((key) => {
const dv = desktop.vitals[key];
const mv = mobile.vitals[key];
// Worst status: Poor > Needs improvement > Good > N/A
const rank = { 'Poor': 3, 'Needs improvement': 2, 'Good': 1, 'N/A': 0 };
const worstStatus = rank[dv.status] >= rank[mv.status] ? dv.status : mv.status;
return `| ${dv.label} | ${dv.value} | ${mv.value} | ${worstStatus} |`;
});
const vitalsTable = [
'| Metric | Desktop | Mobile | Status |',
'|---|---|---|---|',
...vitalRows,
].join('\n');
parts.push(`## Core Web Vitals\n\n${vitalsTable}`);
// Merge findings
const { critical, warnings } = mergeFindings(lighthouseResults);
// DOM-level warnings
const domWarnings = [];
if (!domData.meta.description) {
domWarnings.push('- [ ] **Meta description missing** — search engines will auto-generate one, often poorly. Add a 155-char summary.');
}
if (domData.headings.skips.length > 0) {
for (const skip of domData.headings.skips) {
domWarnings.push(`- [ ] **Heading hierarchy skips ${skip}** — this confuses screen readers and weakens document structure.`);
}
}
if (domData.images.missingAlt > 0) {
domWarnings.push(`- [ ] **${domData.images.missingAlt} image(s) lack alt text** — WCAG 1.1.1 violation (Level A); screen readers will skip them entirely.`);
}
if (domData.links.externalNewTabNoOpener > 0) {
domWarnings.push(`- [ ] **${domData.links.externalNewTabNoOpener} link(s) open in a new tab without \`rel="noopener"\`** — the opened page can access \`window.opener\`.`);
}
// Critical section
const criticalLines = critical.map(buildFindingLine);
parts.push(
`## Critical Issues (must fix)\n\n` +
(criticalLines.length ? criticalLines.join('\n') : '_None detected._')
);
// Warnings section
const warningLines = [...warnings.map(buildFindingLine), ...domWarnings];
parts.push(
`## Warnings (should fix)\n\n` +
(warningLines.length ? warningLines.join('\n') : '_None detected._')
);
// Page Structure
const h1s = domData.headings.h1;
const h1Display = h1s.length === 0 ? 'none' : h1s.length === 1 ? `"${h1s[0]}"` : `${h1s.length} tags`;
const headingCounts = ['h1','h2','h3','h4','h5','h6']
.map((h) => `${h.toUpperCase()}×${domData.headings[h].length}`)
.filter((s) => !s.endsWith('×0'))
.join(', ') || 'none';
const sdTypes = domData.structuredData.length === 0
? 'none'
: domData.structuredData.map((s) => s?.['@type'] ?? 'unknown').join(', ');
const structureLines = [
`- **Title:** ${domData.title ? `"${domData.title}" (${domData.titleLength} chars — ${titleAssessment(domData.titleLength)})` : 'missing'}`,
`- **H1:** ${h1Display}`,
`- **Meta description:** ${domData.meta.description ? `"${domData.meta.description.slice(0, 80)}${domData.meta.description.length > 80 ? '...' : ''}" (${domData.meta.descriptionLength} chars)` : 'missing'}`,
`- **Canonical:** ${domData.canonical || 'not set'}`,
`- **Heading hierarchy:** ${headingCounts}${domData.headings.skips.length ? ` ⚠ skips: ${domData.headings.skips.join(', ')}` : ''}`,
`- **Images:** ${domData.images.total} total${domData.images.missingAlt ? `, ${domData.images.missingAlt} missing alt text` : ', all have alt text'}`,
`- **Links:** ${domData.links.total} total, ${domData.links.external} external${domData.links.externalNewTabNoOpener ? `, ${domData.links.externalNewTabNoOpener} open new tab without noopener` : ''}`,
`- **Structured data:** ${sdTypes}`,
`- **Open Graph:** ${domData.openGraph.title ? 'present' : 'not set'}`,
`- **Viewport meta:** ${domData.meta.viewport || 'not set'}`,
];
parts.push(`## Page Structure\n\n${structureLines.join('\n')}`);
// Screenshots
parts.push(
`## Screenshots\n\n` +
`![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');
}

127
src/screenshot.js Normal file
View File

@@ -0,0 +1,127 @@
const DESKTOP_VIEWPORT = { width: 1440, height: 900, deviceScaleFactor: 1 };
const MOBILE_VIEWPORT = {
width: 375,
height: 812,
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
};
const MOBILE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
export async function captureScreenshots(browser, url, screenshotsDir) {
// Desktop screenshot + DOM extraction in one page load
const desktopPage = await browser.newPage();
await desktopPage.setViewportSize(DESKTOP_VIEWPORT);
await desktopPage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await desktopPage.screenshot({
path: `${screenshotsDir}/desktop.png`,
fullPage: true,
});
// Capture DOM data from the already-loaded desktop page (handed back to index.js)
const rawDomSnapshot = await desktopPage.evaluate(extractDomSnapshot);
await desktopPage.close();
// Mobile screenshot (separate page to avoid viewport bleed)
const mobilePage = await browser.newPage();
await mobilePage.setViewportSize(MOBILE_VIEWPORT);
await mobilePage.setExtraHTTPHeaders({ 'user-agent': MOBILE_UA });
await mobilePage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
await mobilePage.screenshot({
path: `${screenshotsDir}/mobile.png`,
fullPage: true,
});
await mobilePage.close();
return { domData: rawDomSnapshot };
}
// Runs inside the browser context — must be serialisable
function extractDomSnapshot() {
const getMeta = (name) => {
const el = document.querySelector(`meta[name="${name}"], meta[property="${name}"]`);
return el?.content?.trim() || null;
};
const title = document.title?.trim() || null;
const description = getMeta('description');
const robots = getMeta('robots');
const viewport = getMeta('viewport');
const canonical = document.querySelector('link[rel="canonical"]')?.href?.trim() || null;
const openGraph = {
title: getMeta('og:title'),
description: getMeta('og:description'),
image: getMeta('og:image'),
type: getMeta('og:type'),
};
// Headings
const headingTags = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
const headings = { h1: [], h2: [], h3: [], h4: [], h5: [], h6: [], skips: [] };
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((el) => {
const key = el.tagName.toLowerCase();
headings[key].push(el.textContent?.trim().slice(0, 120) || '');
});
// Detect heading level skips
const allHeadings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
for (let i = 1; i < allHeadings.length; i++) {
const prev = parseInt(allHeadings[i - 1].tagName[1]);
const curr = parseInt(allHeadings[i].tagName[1]);
if (curr > prev + 1) {
headings.skips.push(`H${prev}→H${curr}`);
}
}
// Deduplicate skips
headings.skips = [...new Set(headings.skips)];
// Images
const allImages = Array.from(document.querySelectorAll('img'));
const missingAltImgs = allImages.filter(
(img) => !img.hasAttribute('alt') || img.alt.trim() === ''
);
const images = {
total: allImages.length,
missingAlt: missingAltImgs.length,
missingAltSrcs: missingAltImgs.slice(0, 5).map((img) => (img.src || '').slice(0, 80)),
};
// Links
const allLinks = Array.from(document.querySelectorAll('a[href]'));
const origin = window.location.origin;
const externalLinks = allLinks.filter((a) => {
try { return new URL(a.href).origin !== origin; } catch { return false; }
});
const externalNewTab = externalLinks.filter((a) => a.target === '_blank');
const externalNewTabNoOpener = externalNewTab.filter(
(a) => !(a.rel || '').split(/\s+/).includes('noopener')
);
const links = {
total: allLinks.length,
external: externalLinks.length,
externalNewTab: externalNewTab.length,
externalNewTabNoOpener: externalNewTabNoOpener.length,
};
// Structured data
const structuredData = [];
document.querySelectorAll('script[type="application/ld+json"]').forEach((el) => {
try { structuredData.push(JSON.parse(el.textContent)); }
catch { structuredData.push(el.textContent?.trim()); }
});
return {
title,
titleLength: title?.length ?? 0,
meta: { description, descriptionLength: description?.length ?? null, robots, viewport },
canonical,
openGraph,
headings,
images,
links,
structuredData,
};
}