Claude AI

Core Web Vitals Audit Claude Skill

This skill audits a website's Core Web Vitals and page-experience signals and produces an actionable performance report. It optionally pulls real-world CrUX field data from Google PageSpeed Insights and always collects browserless lab proxies, render-blocking CSS and JS, page weight, image dimensions and lazy-loading, TTFB, text compression, DOM size, and third-party origins, then recommends fixes tied to the measured data.

What The Skill Checks

  • Real-world field vitals per page: LCP, CLS, INP, and TTFB, pulled from the Chrome UX Report via PageSpeed Insights when an API key is supplied, used as the verdict on whether each page passes.
  • Browserless lab proxies that diagnose root causes: render-blocking CSS and JS counts, total and inline scripts, page weight, missing text compression, oversized DOM, request counts, and third-party origins.
  • Image hygiene that drives layout shift and slow loads: missing width and height attributes, lazy-loaded LCP hero images, and missing preconnect hints, each mapped to the vital it affects and ranked by severity.

How The Skill Is Packaged

The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that collect performance signals and audit them against your budgets. Every report can be exported to a shareable Word .docx or a flat .csv of findings via the bundled md_to_docx.py and findings_to_csv.py scripts. Install it with npx claude-seo-skills install core-web-vitals-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.

Skill Files

Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.

SKILL.md

The skill definition: frontmatter, inputs to collect (including an optional PageSpeed Insights API key and performance budgets), the five-step workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
core-web-vitals-audit
description
Audit a website's Core Web Vitals and page-experience signals (LCP, CLS, INP, TTFB). Optionally pulls real field data from Google PageSpeed Insights (CrUX) and always collects browserless lab proxies — render-blocking CSS/JS, page weight, image dimension and lazy-loading hygiene, TTFB, text compression, DOM size, request counts, and third-party origins. Use when the user asks to audit, analyze, check, or improve Core Web Vitals, page speed, performance, LCP, CLS, INP, page experience, or loading speed of a website.

Core Web Vitals Audit

Audit the Core Web Vitals and page-experience signals of a website and produce an actionable report.

When to use this skill

Use this skill when the user asks to:

  • Audit or improve Core Web Vitals, page speed, or page experience
  • Diagnose poor LCP (Largest Contentful Paint), CLS (Cumulative Layout Shift), or INP (Interaction to Next Paint)
  • Find render-blocking resources, oversized pages, slow TTFB, or layout-shift causes
  • Check that images have dimensions and that the hero/LCP image is not lazy-loaded
  • Pull real-world field performance data from Google PageSpeed Insights / CrUX
How this skill measures

Core Web Vitals are ultimately a field metric (real users, measured in CrUX). This skill collects two complementary layers:

  1. Lab proxies (always, no browser required): static signals that strongly correlate with the vitals — measured TTFB, page weight, render-blocking CSS/JS, image dimension/lazy-loading hygiene, DOM size, request counts, third-party origins, text compression, and resource hints. Fast and dependency-free, but an approximation.
  2. PSI field + lab (optional, --psi): real CrUX field metrics (LCP, CLS, INP, FCP, TTFB) plus a Lighthouse lab score from Google PageSpeed Insights. This is the authoritative source; use it when you need real numbers and have network access.

When PSI data is present, treat it as the verdict ("is this page passing?") and use the lab proxies to explain why and what to fix.

Inputs to collect

Before starting, confirm with the user:

  1. Site URL or URL set — a live site root URL, a sitemap (--sitemap), or a URL list (--url-list). Pick representative templates (home, a product/article, a category) rather than the whole site.
  2. Scope — max pages (default 50; collection is heavier than a text crawl because it fetches subresources).
  3. PSI — whether to fetch PageSpeed Insights field data (--psi), the strategy (mobile default, or desktop), and an optional API key (--psi-key) to raise rate limits.
  4. Budgets — optional overrides for total page weight, TTFB, JS/CSS size, and DOM node count.
Workflow
Step 1: Collect the signals

Use scripts/collect_vitals.py:

python3 scripts/collect_vitals.py https://example.com --max-pages 50 --output vitals_inventory.json

Add real field data from PageSpeed Insights (recommended when available):

python3 scripts/collect_vitals.py https://example.com --psi --psi-strategy mobile --psi-key YOUR_KEY --output vitals_inventory.json

The inventory records, per page: measured TTFB and total fetch time, HTML size and compression, render-blocking CSS/JS counts, total/inline scripts, image dimension and lazy-loading hygiene, whether the first (likely LCP) image is lazy-loaded, approximate DOM node count, resource hints, third-party origins, a weight breakdown (CSS/JS/image/total bytes and request count), and the PSI block when enabled.

Step 2: Run the audit checks

Use scripts/audit_vitals.py:

python3 scripts/audit_vitals.py vitals_inventory.json --output audit_report.json

Override budgets if needed, e.g. --weight-budget 2000000 --ttfb-budget 600 --dom-budget 1400.

Step 3: Evaluate the audit checks

Evaluate each check defined in references/audit-checks.md. The core checks are:

CheckVitalSeverity
PSI field LCP poor (SLOW/AVERAGE)LCPHigh / Medium
PSI field CLS poorCLSHigh / Medium
PSI field INP poorINPHigh / Medium
PSI Lighthouse score < 50 / < 90OverallHigh / Medium
No text compression (gzip/br)LCP/TTFBHigh
LCP image is lazy-loadedLCPHigh
Slow TTFB (> budget)LCP/INPHigh / Medium
Excessive DOM sizeINP/CLSHigh / Medium
Page weight over budgetLCPMedium
Render-blocking JS in headLCP/INPMedium
Render-blocking CSS (> 2 files)LCPMedium
Images missing width/heightCLSMedium
Too many requests (> 80)LCPMedium
Large JS payload (> budget)INPMedium
Missing viewport meta—Medium
Missing preconnect for third partiesLCPLow
Large CSS payloadLCPLow
No lazy-loading on image-heavy pageLCPLow
Step 4: Produce the report

Write a report following the structure in references/report-template.md:

  1. Summary — pages analyzed, whether PSI field data was used, median page weight, issue counts by severity
  2. Field vitals (if PSI used) — per page: LCP / CLS / INP with pass/fail
  3. Issues — one section per failing check, mapped to the vital it affects, with the page, the measurement, and the fix
  4. Prioritized action list — ordered by severity and by which vital is failing in the field
Step 5: Recommend fixes

Map every fix to the vital it improves and quantify where possible:

  • LCP: preload the hero image, never lazy-load it, add fetchpriority="high"; enable text compression; cut render-blocking CSS/JS (inline critical CSS, defer/async scripts); reduce TTFB (caching/CDN); compress and right-size the LCP image.
  • CLS: add width/height (or aspect-ratio) to every image and embed; reserve space for ads/embeds; preload fonts and use font-display: optional/swap.
  • INP: reduce and split long JavaScript tasks; remove unused JS; defer non-critical third-party scripts; shrink an oversized DOM.
  • Base every recommendation on the measured data (e.g., "your LCP image /hero.jpg has loading="lazy" — remove it"); never invent metrics. If PSI field data is unavailable for a URL (low traffic), say so and lean on the lab proxies.
Optional: export the report
As a Word document (.docx)

If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:

python3 scripts/md_to_docx.py report.md --output report.docx

scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.

As a CSV of findings (.csv)

If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:

python3 scripts/findings_to_csv.py audit_report.json --output findings.csv

scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.

Resources
  • scripts/md_to_docx.py — convert the Markdown report into a Word (.docx) document (standard library only)
  • scripts/findings_to_csv.py — flatten the audit findings JSON into a CSV, one row per finding (standard library only)
  • references/audit-checks.md — full definitions, thresholds, vital mapping, and rationale for every check
  • references/report-template.md — report output structure
  • scripts/collect_vitals.py — collect lab proxies (and optional PSI field/lab data) for a set of pages
  • scripts/audit_vitals.py — run audit checks against a vitals inventory JSON file

Audit Checks Reference (references/audit-checks.md)

Full definitions, thresholds, and rationale for all fifteen checks, from poor field metrics and missing compression to lazy-loaded LCP images and missing preconnect hints.

references/audit-checks.mdView on GitHub

Core Web Vitals Audit Checks

Full definitions, thresholds, vital mapping, and rationale for each audit check.

Background: the three Core Web Vitals
VitalMeasures"Good" (field, 75th percentile)
LCP — Largest Contentful PaintLoading: time until the largest element renders≤ 2.5 s
CLS — Cumulative Layout ShiftVisual stability: unexpected layout movement≤ 0.1
INP — Interaction to Next PaintResponsiveness: latency of user interactions≤ 200 ms

LCP/CLS/INP are field metrics (real users, reported in CrUX). PSI checks below read the field data directly; the lab-proxy checks diagnose the likely causes without a browser.

PSI field checks (only when collected with --psi)

PageSpeed Insights returns a category per metric: FAST, AVERAGE, or SLOW. These checks are the authoritative pass/fail.

1. PSI field LCP poor
  • Definition: CrUX LCP category is SLOW (high) or AVERAGE (medium).
  • Fix: See the LCP fixes below; start with the LCP image and render-blocking resources.
2. PSI field CLS poor
  • Definition: CrUX CLS category is SLOW (high) or AVERAGE (medium).
  • Fix: See the CLS fixes below; start with image/embed dimensions and fonts.
3. PSI field INP poor
  • Definition: CrUX INP category is SLOW (high) or AVERAGE (medium).
  • Fix: See the INP fixes below; start with JavaScript execution and DOM size.
4. PSI Lighthouse score low
  • Definition: Lab performance score < 50 (high) or < 90 (medium).
  • Why it matters: A controlled lab snapshot of the page's performance; useful when field data is sparse.
  • Fix: Address the highest-severity lab-proxy checks below.
Lab-proxy checks (always)
5. No text compression — affects LCP, TTFB (high)
  • Definition: The HTML response is served without gzip/br/deflate and is larger than 5KB.
  • Why it matters: Uncompressed HTML (and CSS/JS) inflates transfer size and delays first paint and LCP. This is one of the cheapest wins available.
  • Fix: Enable gzip or Brotli at the server/CDN for all text assets (HTML, CSS, JS, SVG, JSON).
6. LCP image is lazy-loaded — affects LCP (high)
  • Definition: The first content image on the page has loading="lazy".
  • Why it matters: Lazy-loading the above-the-fold/hero image (a common LCP element) delays its request until layout, directly worsening LCP. A frequent and high-impact mistake from blanket lazy-loading.
  • Fix: Remove loading="lazy" from the LCP image; add fetchpriority="high" and consider preloading it. Keep lazy-loading for below-the-fold images only.
7. Slow TTFB — affects LCP, INP (high > 1.8s, medium > budget)
  • Definition: Measured time to first byte exceeds the budget (default 800 ms). Escalate to high above ~1.8 s.
  • Why it matters: TTFB is the server-response floor under every other metric; a slow TTFB makes good LCP/INP nearly impossible.
  • Fix: Add/​tune caching and a CDN, reduce server work, use HTTP/2+; investigate origin latency and database/render time. Note: measured TTFB here includes network conditions from the audit machine; corroborate with PSI field TTFB.
8. Excessive DOM size — affects INP, CLS (high > 3000, medium > budget)
  • Definition: Approximate DOM node count exceeds the soft limit (default 1500); escalate above ~3000.
  • Why it matters: A large DOM increases style/layout cost and slows interaction handling (INP) and rendering.
  • Fix: Simplify markup, virtualize long lists, remove wrapper-div bloat, and avoid rendering off-screen content.
9. Page weight over budget — affects LCP (medium)
  • Definition: Total transfer (HTML + CSS + JS + images) exceeds the budget (default 2.5MB).
  • Why it matters: Heavy pages load slowly, especially on mobile networks, hurting LCP.
  • Fix: Compress images and serve modern formats, remove unused CSS/JS, split bundles, and lazy-load below-the-fold media.
10. Render-blocking JavaScript — affects LCP, INP (medium)
  • Definition: One or more <script src> in <head> without async, defer, or type="module".
  • Why it matters: Parser-blocking scripts delay rendering and first paint.
  • Fix: Add defer (or async for independent scripts), move non-critical JS to the end of <body>, and remove unused scripts.
11. Render-blocking CSS — affects LCP (medium when > 2 files)
  • Definition: More than two render-blocking stylesheets in <head>.
  • Why it matters: CSS blocks rendering until downloaded and parsed; many separate stylesheets serialize that cost.
  • Fix: Inline critical CSS, combine stylesheets, and load non-critical CSS asynchronously (e.g., media swap or preload).
12. Images missing width/height — affects CLS (medium)
  • Definition: One or more <img> without both width and height attributes.
  • Why it matters: Without intrinsic dimensions the browser cannot reserve space, causing layout shift as images load — a leading CLS cause.
  • Fix: Add width and height (or CSS aspect-ratio) to every image so space is reserved before load.
13. Too many requests — affects LCP (medium > 80)
  • Definition: More than 80 subresource requests on the page.
  • Why it matters: Each request adds connection/scheduling overhead and contends for bandwidth.
  • Fix: Bundle assets, use sprites/icon fonts/SVG, lazy-load below-the-fold media, and remove unused third parties.
14. Large JavaScript payload — affects INP (medium > budget)
  • Definition: Total JS transfer exceeds the budget (default 1MB).
  • Why it matters: Large JS means long parse/compile/execute time, the primary driver of poor INP and TBT.
  • Fix: Code-split, tree-shake, defer non-critical JS, and audit heavy third-party scripts.
15. Missing viewport meta (medium)
  • Definition: No <meta name="viewport">.
  • Why it matters: Without it, mobile browsers render at desktop width and zoom out, harming mobile usability and the page-experience signal.
  • Fix: Add <meta name="viewport" content="width=device-width, initial-scale=1">.
16. Missing preconnect for third-party origins — affects LCP (low)
  • Definition: The page loads from third-party origins but declares no preconnect hints.
  • Why it matters: Early connection setup (DNS/TCP/TLS) to critical third parties (fonts, CDNs) shaves time off resource loads.
  • Fix: Add <link rel="preconnect"> (with crossorigin where needed) for the most important third-party origins.
17. Large CSS payload — affects LCP (low > budget)
  • Definition: Total CSS transfer exceeds the budget (default 150KB).
  • Fix: Remove unused CSS, split by route, and minify.
18. No lazy-loading on an image-heavy page — affects LCP (low)
  • Definition: More than five images and none use loading="lazy".
  • Why it matters: Below-the-fold images compete for bandwidth with critical resources during initial load.
  • Fix: Add loading="lazy" to below-the-fold images — but never to the LCP image (see check 6).
Notes
  • Measured TTFB and fetch times reflect the audit machine's network; PSI field TTFB is more representative of real users. Prefer PSI when both are available.
  • Brotli-compressed HTML is only decoded if the brotli package is installed; otherwise compression is still detected via the Content-Encoding header.
  • The lab proxies cannot directly measure CLS or INP — they flag the structural causes (missing dimensions, heavy JS, large DOM). Use --psi for the actual field values.

Report Template (references/report-template.md)

The output structure the skill follows, including pages analyzed, median metrics, per-page field vitals, and issues grouped by severity.

references/report-template.mdView on GitHub

Core Web Vitals Audit Report Template

Use this structure for the final report delivered to the user.

# Core Web Vitals Audit — {site}

**Date:** {date}
**Scope:** {scope description}
**Pages analyzed:** {n} | **Field data (PSI):** {yes/no, strategy} | **Median page weight:** {MB}

## Summary

| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |

{2–3 sentence overview: which vital is the biggest problem and the highest-leverage fixes.}

## Field vitals (PSI / CrUX)

*Only include this section when collected with --psi.*

| Page | LCP | CLS | INP | Lab score |
|---|---|---|---|---|
| / | 3.8s (SLOW) | 0.04 (GOOD) | 240ms (AVERAGE) | 62 |
| ... | ... | ... | ... | ... |

## Issues by vital

### LCP (Largest Contentful Paint)

| Page | Finding | Measurement | Fix |
|---|---|---|---|
| / | LCP image lazy-loaded | loading="lazy" on /hero.jpg | Remove lazy; add fetchpriority="high" + preload |
| / | No text compression | HTML 41KB uncompressed | Enable Brotli/gzip |
| / | Render-blocking JS | 2 scripts in head | Add defer |

### CLS (Cumulative Layout Shift)

| Page | Finding | Measurement | Fix |
|---|---|---|---|
| / | Images missing dimensions | 9 of 24 images | Add width/height or aspect-ratio |

### INP (Interaction to Next Paint)

| Page | Finding | Measurement | Fix |
|---|---|---|---|
| / | Large JS payload | 1.4 MB JS | Code-split, defer, drop unused third parties |
| / | Excessive DOM | 2,310 nodes | Simplify markup, virtualize lists |

### Other page-experience issues

{Missing viewport, missing preconnect, too many requests, page weight, etc.}

## Prioritized action list

1. {Highest-impact fix, naming the page, the measurement, and the expected vital improvement}
2. ...
Guidance
  • Map every finding to the vital it affects (LCP / CLS / INP) so the user can prioritize by what is actually failing in the field.
  • When PSI field data exists, lead with it (the verdict) and use lab proxies to explain the cause. When it does not exist for a URL, say so and rely on lab proxies.
  • Cite the concrete measurement in every row (the file, the byte count, the node count, the attribute) — never generic advice like "optimize images".
  • The single highest-leverage LCP fixes are usually: stop lazy-loading the LCP image, enable text compression, and remove render-blocking resources. Call these out first when present.
  • Note that measured TTFB reflects the audit machine's network; corroborate with PSI field TTFB before making server-side recommendations.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Order the action list by severity first, then by which Core Web Vital is failing in the field.

Vitals Collector Script (scripts/collect_vitals.py)

Collects browserless lab proxies for each page, TTFB, render-blocking resources, page weight, image metadata, DOM size, and compression, and optionally pulls CrUX field data from the PageSpeed Insights API.

scripts/collect_vitals.pyView on GitHub
#!/usr/bin/env python3
"""Collect Core Web Vitals signals for a set of pages into a JSON inventory.

Usage:
    python3 collect_vitals.py https://example.com [--max-pages 50] [--output vitals_inventory.json]
    python3 collect_vitals.py https://example.com/sitemap.xml --sitemap [--max-pages 50]
    python3 collect_vitals.py urls.txt --url-list [--max-pages 50]

    # Add real field + lab data from Google PageSpeed Insights (CrUX + Lighthouse):
    python3 collect_vitals.py https://example.com --psi --psi-strategy mobile [--psi-key YOUR_KEY]

Two layers of data are collected:

1. LAB PROXIES (always, stdlib only) — static signals that correlate with the
   Core Web Vitals without a real browser: measured TTFB, HTML transfer size and
   compression, render-blocking CSS/JS in the head, total subresource weight by
   type, request counts, image dimension/lazy-loading hygiene (CLS / LCP), an
   approximate DOM node count, third-party origins, and resource hints present.

2. PSI FIELD + LAB (optional, --psi) — Google PageSpeed Insights returns real
   CrUX field metrics (LCP, CLS, INP, FCP, TTFB) and a Lighthouse lab score.
   This is the authoritative Core Web Vitals source; the lab proxies above are a
   fast, browserless approximation for when PSI is unavailable or rate-limited.

Output JSON schema (per page):
{
  "status": 200, "final_url": "...", "ttfb_ms": 180, "fetch_ms": 540,
  "html_bytes": 41234, "html_compressed": true, "html_cache_control": "...|null",
  "render_blocking_css": 3, "render_blocking_js": 2, "total_scripts": 11,
  "inline_script_bytes": 8000, "inline_style_bytes": 1200,
  "image_count": 24, "images_missing_dimensions": 9, "images_no_lazy": 20,
  "lcp_image_lazy": true, "dom_nodes": 1640,
  "resource_hints": {"preconnect": 1, "dns_prefetch": 0, "preload": 2, "modulepreload": 0},
  "has_viewport": true,
  "weight": {"css_bytes": 120000, "js_bytes": 480000, "img_bytes": 900000,
             "total_bytes": 1541234, "requests": 38},
  "third_party_origins": ["fonts.googleapis.com", ...],
  "psi": { ... } | null
}

Uses only the Python standard library.
"""

import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser

USER_AGENT = "CoreWebVitalsAuditBot/1.0 (+claude-skill)"
TIMEOUT = 20
SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"

# Resource size cache shared across all pages so common assets are fetched once.
_SIZE_CACHE = {}


class VitalsParser(HTMLParser):
    """Extract render-blocking resources, images, hints, scripts, and DOM size."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.in_head = False
        self.head_done = False
        self.dom_nodes = 0
        self.render_blocking_css = 0
        self.render_blocking_js = 0
        self.total_scripts = 0
        self.inline_script_bytes = 0
        self.inline_style_bytes = 0
        self.css_urls = []
        self.js_urls = []
        self.img_urls = []
        self.image_count = 0
        self.images_missing_dimensions = 0
        self.images_no_lazy = 0
        self.first_image_lazy = None
        self.has_viewport = False
        self.hints = {"preconnect": 0, "dns_prefetch": 0, "preload": 0, "modulepreload": 0}
        self.page_links = []
        self._in_script = False
        self._in_style = False
        self._script_is_inline = False

    def handle_starttag(self, tag, attrs):
        self.dom_nodes += 1
        a = dict(attrs)
        if tag == "head":
            self.in_head = True
        elif tag == "body":
            self.in_head = False
            self.head_done = True
        elif tag == "meta" and (a.get("name") or "").lower() == "viewport":
            self.has_viewport = True
        elif tag == "link":
            rel = (a.get("rel") or "").lower()
            href = a.get("href")
            if rel == "stylesheet" and href:
                media = (a.get("media") or "").lower()
                url = urllib.parse.urljoin(self.base_url, href.strip())
                self.css_urls.append(url)
                # Render-blocking unless explicitly print-only or preload.
                if media not in ("print",) and "preload" not in rel:
                    if not self.head_done:
                        self.render_blocking_css += 1
            elif rel == "preconnect":
                self.hints["preconnect"] += 1
            elif rel == "dns-prefetch":
                self.hints["dns_prefetch"] += 1
            elif rel == "preload":
                self.hints["preload"] += 1
            elif rel == "modulepreload":
                self.hints["modulepreload"] += 1
        elif tag == "script":
            src = a.get("src")
            is_async = "async" in a or "defer" in a
            is_module = (a.get("type") or "").lower() == "module"
            if src:
                self.total_scripts += 1
                self.js_urls.append(urllib.parse.urljoin(self.base_url, src.strip()))
                if not self.head_done and not is_async and not is_module:
                    self.render_blocking_js += 1
            else:
                self._in_script = True
                self._script_is_inline = True
        elif tag == "style":
            self._in_style = True
        elif tag == "img":
            self.image_count += 1
            has_dims = a.get("width") and a.get("height")
            if not has_dims:
                self.images_missing_dimensions += 1
            loading = (a.get("loading") or "").lower()
            if loading != "lazy":
                self.images_no_lazy += 1
            if self.first_image_lazy is None:
                self.first_image_lazy = (loading == "lazy")
            if a.get("src"):
                self.img_urls.append(urllib.parse.urljoin(self.base_url, a["src"].strip()))
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                self.page_links.append(
                    urllib.parse.urldefrag(urllib.parse.urljoin(self.base_url, href))[0])

    def handle_endtag(self, tag):
        if tag == "script" and self._in_script:
            self._in_script = False
        elif tag == "style" and self._in_style:
            self._in_style = False

    def handle_data(self, data):
        if self._in_script:
            self.inline_script_bytes += len(data.encode("utf-8"))
        elif self._in_style:
            self.inline_style_bytes += len(data.encode("utf-8"))


def norm_host(host):
    host = host.lower()
    return host[4:] if host.startswith("www.") else host


def fetch_page(url):
    """Fetch a page (follows redirects). Returns (status, final_url, body, headers, ttfb_ms, fetch_ms)."""
    req = urllib.request.Request(
        url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip, br"})
    t0 = time.perf_counter()
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            first = resp.read(1)
            ttfb = (time.perf_counter() - t0) * 1000
            rest = resp.read(5_000_000)
            fetch_ms = (time.perf_counter() - t0) * 1000
            raw = first + rest
            return resp.status, resp.geturl(), raw, resp.headers, ttfb, fetch_ms
    except urllib.error.HTTPError as e:
        return e.code, url, b"", (e.headers if e.headers else None), None, None
    except Exception:
        return 0, url, b"", None, None, None


def resource_size(url):
    """Return (transfer_bytes, cache_control) for a subresource, cached. Uses GET with gzip."""
    if url in _SIZE_CACHE:
        return _SIZE_CACHE[url]
    req = urllib.request.Request(
        url, headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip, br"})
    result = (0, None)
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            cl = resp.headers.get("Content-Length")
            if cl and cl.isdigit():
                size = int(cl)
            else:
                size = len(resp.read(10_000_000))
            result = (size, resp.headers.get("Cache-Control"))
    except Exception:
        result = (0, None)
    _SIZE_CACHE[url] = result
    return result


def decode_html(raw, headers):
    enc = (headers.get("Content-Encoding", "") if headers else "").lower()
    data = raw
    try:
        if "gzip" in enc:
            import gzip
            data = gzip.decompress(raw)
        elif "br" in enc:
            try:
                import brotli  # not stdlib; only if available
                data = brotli.decompress(raw)
            except Exception:
                data = raw
    except Exception:
        data = raw
    return data.decode("utf-8", errors="replace")


def has_compression(headers):
    enc = (headers.get("Content-Encoding", "") if headers else "").lower()
    return "gzip" in enc or "br" in enc or "deflate" in enc


def fetch_psi(url, strategy, key):
    params = {"url": url, "category": "performance", "strategy": strategy}
    if key:
        params["key"] = key
    q = PSI_ENDPOINT + "?" + urllib.parse.urlencode(params)
    try:
        req = urllib.request.Request(q, headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read())
    except Exception as e:
        return {"error": str(e)}
    out = {"strategy": strategy}
    le = data.get("loadingExperience", {}).get("metrics", {})

    def field(key_name):
        m = le.get(key_name)
        if not m:
            return None
        return {"percentile": m.get("percentile"), "category": m.get("category")}

    out["field"] = {
        "LCP": field("LARGEST_CONTENTFUL_PAINT_MS"),
        "CLS": field("CUMULATIVE_LAYOUT_SHIFT_SCORE"),
        "INP": field("INTERACTION_TO_NEXT_PAINT"),
        "FCP": field("FIRST_CONTENTFUL_PAINT_MS"),
        "TTFB": field("EXPERIMENTAL_TIME_TO_FIRST_BYTE"),
    }
    lh = data.get("lighthouseResult", {})
    cats = lh.get("categories", {}).get("performance", {})
    out["lab_score"] = round(cats.get("score") * 100) if cats.get("score") is not None else None
    audits = lh.get("audits", {})

    def lab(metric):
        a = audits.get(metric, {})
        return a.get("numericValue")

    out["lab"] = {
        "LCP_ms": lab("largest-contentful-paint"),
        "CLS": lab("cumulative-layout-shift"),
        "TBT_ms": lab("total-blocking-time"),
        "FCP_ms": lab("first-contentful-paint"),
        "SI_ms": lab("speed-index"),
    }
    return out


def collect_page(url, do_psi, psi_strategy, psi_key):
    status, final_url, raw, headers, ttfb, fetch_ms = fetch_page(url)
    page = {
        "status": status, "final_url": final_url,
        "ttfb_ms": round(ttfb) if ttfb else None,
        "fetch_ms": round(fetch_ms) if fetch_ms else None,
        "html_bytes": len(raw), "html_compressed": has_compression(headers),
        "html_cache_control": headers.get("Cache-Control") if headers else None,
        "render_blocking_css": 0, "render_blocking_js": 0, "total_scripts": 0,
        "inline_script_bytes": 0, "inline_style_bytes": 0,
        "image_count": 0, "images_missing_dimensions": 0, "images_no_lazy": 0,
        "lcp_image_lazy": False, "dom_nodes": 0,
        "resource_hints": {"preconnect": 0, "dns_prefetch": 0, "preload": 0, "modulepreload": 0},
        "has_viewport": False,
        "weight": {"css_bytes": 0, "js_bytes": 0, "img_bytes": 0, "total_bytes": 0, "requests": 0},
        "third_party_origins": [], "psi": None, "_links": [],
    }
    if not raw or "html" not in (headers.get("Content-Type", "") if headers else ""):
        return page

    html = decode_html(raw, headers)
    parser = VitalsParser(final_url)
    try:
        parser.feed(html)
    except Exception:
        pass

    page.update({
        "render_blocking_css": parser.render_blocking_css,
        "render_blocking_js": parser.render_blocking_js,
        "total_scripts": parser.total_scripts,
        "inline_script_bytes": parser.inline_script_bytes,
        "inline_style_bytes": parser.inline_style_bytes,
        "image_count": parser.image_count,
        "images_missing_dimensions": parser.images_missing_dimensions,
        "images_no_lazy": parser.images_no_lazy,
        "lcp_image_lazy": bool(parser.first_image_lazy),
        "dom_nodes": parser.dom_nodes,
        "resource_hints": parser.hints,
        "has_viewport": parser.has_viewport,
        "_links": parser.page_links,
    })

    site_host = norm_host(urllib.parse.urlparse(final_url).netloc)
    third_party = set()
    css_bytes = js_bytes = img_bytes = 0
    requests = 1  # the HTML document itself
    for u in parser.css_urls:
        size, _cc = resource_size(u)
        css_bytes += size
        requests += 1
        h = norm_host(urllib.parse.urlparse(u).netloc)
        if h and h != site_host:
            third_party.add(h)
    for u in parser.js_urls:
        size, _cc = resource_size(u)
        js_bytes += size
        requests += 1
        h = norm_host(urllib.parse.urlparse(u).netloc)
        if h and h != site_host:
            third_party.add(h)
    for u in parser.img_urls:
        size, _cc = resource_size(u)
        img_bytes += size
        requests += 1
        h = norm_host(urllib.parse.urlparse(u).netloc)
        if h and h != site_host:
            third_party.add(h)

    page["weight"] = {
        "css_bytes": css_bytes, "js_bytes": js_bytes, "img_bytes": img_bytes,
        "total_bytes": page["html_bytes"] + css_bytes + js_bytes + img_bytes,
        "requests": requests,
    }
    page["third_party_origins"] = sorted(third_party)

    if do_psi:
        page["psi"] = fetch_psi(final_url, psi_strategy, psi_key)
    return page


def urls_from_sitemap(source):
    try:
        req = urllib.request.Request(source, headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            body = resp.read(10_000_000).decode("utf-8", errors="replace")
        root = ET.fromstring(body)
    except Exception:
        return []
    if root.tag.lower().endswith("sitemapindex"):
        out = []
        for loc in root.iter(SM_NS + "loc"):
            if loc.text:
                try:
                    req = urllib.request.Request(loc.text.strip(), headers={"User-Agent": USER_AGENT})
                    with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                        sub = ET.fromstring(resp.read(10_000_000).decode("utf-8", errors="replace"))
                    out += [l.text.strip() for l in sub.iter(SM_NS + "loc") if l.text]
                except Exception:
                    continue
        return out
    return [loc.text.strip() for loc in root.iter(SM_NS + "loc") if loc.text]


def main():
    ap = argparse.ArgumentParser(description="Collect Core Web Vitals signals for a site.")
    ap.add_argument("source", help="Site root URL, sitemap (--sitemap), or URL list file (--url-list)")
    ap.add_argument("--max-pages", type=int, default=50,
                    help="max pages to analyze (default 50; CWV collection is heavier than a text crawl)")
    ap.add_argument("--output", default="vitals_inventory.json")
    ap.add_argument("--sitemap", action="store_true")
    ap.add_argument("--url-list", action="store_true")
    ap.add_argument("--psi", action="store_true", help="also fetch Google PageSpeed Insights data")
    ap.add_argument("--psi-strategy", choices=["mobile", "desktop"], default="mobile")
    ap.add_argument("--psi-key", default=None, help="PageSpeed Insights API key (raises rate limits)")
    args = ap.parse_args()

    crawl_mode = not args.url_list and not args.sitemap
    if args.url_list:
        with open(args.source, encoding="utf-8") as f:
            seeds = [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]
    elif args.sitemap:
        seeds = urls_from_sitemap(args.source)
    else:
        seeds = [args.source.rstrip("/")]

    host = norm_host(urllib.parse.urlparse(seeds[0]).netloc) if seeds else ""
    pages = {}
    queue = deque(seeds)
    seen = set(seeds)

    while queue and len(pages) < args.max_pages:
        url = queue.popleft()
        page = collect_page(url, args.psi, args.psi_strategy, args.psi_key)
        links = page.pop("_links", [])
        pages[url] = page
        w = page["weight"]
        print(f"[{page['status']}] {w['total_bytes']//1024}KB {w['requests']} req  "
              f"blockCSS={page['render_blocking_css']} blockJS={page['render_blocking_js']}  {url}",
              file=sys.stderr)
        if crawl_mode:
            for link in links:
                if norm_host(urllib.parse.urlparse(link).netloc) == host and link not in seen \
                        and len(seen) < args.max_pages:
                    seen.add(link)
                    queue.append(link)

    out = {"site": args.source, "psi_enabled": args.psi,
           "psi_strategy": args.psi_strategy if args.psi else None, "pages": pages}
    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)
    print(f"Wrote {len(pages)} pages to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Vitals Auditor Script (scripts/audit_vitals.py)

Runs the audit checks against the collected signal inventory using configurable performance budgets and outputs the structured report data mapped to LCP, CLS, and INP.

scripts/audit_vitals.pyView on GitHub
#!/usr/bin/env python3
"""Audit a vitals inventory JSON (from collect_vitals.py) and emit findings.

Usage:
    python3 audit_vitals.py vitals_inventory.json [--output audit_report.json]
        [--weight-budget 2500000] [--ttfb-budget 800]

Checks implemented (see references/audit-checks.md). When PSI field data is present
it takes precedence as the authoritative Core Web Vitals signal; the lab proxies
are reported alongside as the cause-level diagnosis.

PSI field (real CrUX), when --psi was used at collection time:
- psi_poor_lcp / psi_poor_cls / psi_poor_inp: metric category SLOW (high) or AVERAGE (medium)
- psi_low_score: Lighthouse performance score < 50 (high) or < 90 (medium)

Lab proxies (always):
- no_text_compression (high), lcp_image_lazy (high), slow_ttfb (high/medium),
  page_weight_over_budget (medium), render_blocking_js (medium), render_blocking_css (medium),
  images_missing_dimensions (medium, CLS), excessive_dom (high/medium), too_many_requests (medium),
  large_js (medium), missing_viewport (medium), missing_preconnect (low),
  large_css (low), no_lazy_loading (low)

Uses only the Python standard library.
"""

import argparse
import json
import sys


def main():
    ap = argparse.ArgumentParser(description="Run Core Web Vitals audit checks.")
    ap.add_argument("inventory", help="vitals_inventory.json from collect_vitals.py")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--weight-budget", type=int, default=2_500_000,
                    help="total page transfer budget in bytes (default 2.5MB)")
    ap.add_argument("--ttfb-budget", type=int, default=800,
                    help="TTFB budget in ms (default 800; Google 'good' threshold)")
    ap.add_argument("--js-budget", type=int, default=1_000_000, help="JS transfer budget in bytes")
    ap.add_argument("--css-budget", type=int, default=150_000, help="CSS transfer budget in bytes")
    ap.add_argument("--dom-budget", type=int, default=1500, help="DOM node soft limit")
    args = ap.parse_args()

    with open(args.inventory, encoding="utf-8") as f:
        data = json.load(f)
    pages = data.get("pages", {})

    findings = {
        "psi_poor_lcp": [], "psi_poor_cls": [], "psi_poor_inp": [], "psi_low_score": [],
        "no_text_compression": [], "lcp_image_lazy": [], "slow_ttfb": [],
        "page_weight_over_budget": [], "render_blocking_js": [], "render_blocking_css": [],
        "images_missing_dimensions": [], "excessive_dom": [], "too_many_requests": [],
        "large_js": [], "missing_viewport": [], "missing_preconnect": [],
        "large_css": [], "no_lazy_loading": [],
    }

    weights = []
    for url, p in pages.items():
        if p.get("status") != 200:
            continue
        w = p.get("weight", {})
        weights.append(w.get("total_bytes", 0))

        # --- PSI field data (authoritative) ---
        psi = p.get("psi") or {}
        field = (psi.get("field") or {}) if isinstance(psi, dict) else {}
        for metric, check in (("LCP", "psi_poor_lcp"), ("CLS", "psi_poor_cls"), ("INP", "psi_poor_inp")):
            m = field.get(metric)
            if m and m.get("category") in ("SLOW", "AVERAGE"):
                findings[check].append(
                    {"page": url, "category": m.get("category"), "percentile": m.get("percentile")})
        score = psi.get("lab_score") if isinstance(psi, dict) else None
        if score is not None and score < 90:
            findings["psi_low_score"].append({"page": url, "score": score})

        # --- Lab proxies ---
        if not p.get("html_compressed") and p.get("html_bytes", 0) > 5000:
            findings["no_text_compression"].append({"page": url, "html_bytes": p.get("html_bytes")})

        if p.get("lcp_image_lazy"):
            findings["lcp_image_lazy"].append({"page": url})

        ttfb = p.get("ttfb_ms")
        if ttfb is not None and ttfb > args.ttfb_budget:
            findings["slow_ttfb"].append({"page": url, "ttfb_ms": ttfb})

        total = w.get("total_bytes", 0)
        if total > args.weight_budget:
            findings["page_weight_over_budget"].append(
                {"page": url, "total_bytes": total, "breakdown": w})

        if p.get("render_blocking_js", 0) > 0:
            findings["render_blocking_js"].append(
                {"page": url, "count": p["render_blocking_js"]})
        if p.get("render_blocking_css", 0) > 2:
            findings["render_blocking_css"].append(
                {"page": url, "count": p["render_blocking_css"]})

        if p.get("images_missing_dimensions", 0) > 0:
            findings["images_missing_dimensions"].append(
                {"page": url, "count": p["images_missing_dimensions"],
                 "of_total": p.get("image_count")})

        dom = p.get("dom_nodes", 0)
        if dom > args.dom_budget:
            findings["excessive_dom"].append({"page": url, "dom_nodes": dom})

        if w.get("requests", 0) > 80:
            findings["too_many_requests"].append({"page": url, "requests": w["requests"]})

        if w.get("js_bytes", 0) > args.js_budget:
            findings["large_js"].append({"page": url, "js_bytes": w["js_bytes"]})
        if w.get("css_bytes", 0) > args.css_budget:
            findings["large_css"].append({"page": url, "css_bytes": w["css_bytes"]})

        if not p.get("has_viewport"):
            findings["missing_viewport"].append({"page": url})

        if p.get("third_party_origins") and p.get("resource_hints", {}).get("preconnect", 0) == 0:
            findings["missing_preconnect"].append(
                {"page": url, "third_party_origins": p["third_party_origins"]})

        if (p.get("image_count", 0) > 5
                and p.get("images_no_lazy", 0) == p.get("image_count", 0)):
            findings["no_lazy_loading"].append(
                {"page": url, "image_count": p["image_count"]})

    # Escalate severity for the worst cases.
    severity = {
        "psi_poor_lcp": "high", "psi_poor_cls": "high", "psi_poor_inp": "high",
        "psi_low_score": "high", "no_text_compression": "high", "lcp_image_lazy": "high",
        "slow_ttfb": "high", "excessive_dom": "high",
        "page_weight_over_budget": "medium", "render_blocking_js": "medium",
        "render_blocking_css": "medium", "images_missing_dimensions": "medium",
        "too_many_requests": "medium", "large_js": "medium", "missing_viewport": "medium",
        "missing_preconnect": "low", "large_css": "low", "no_lazy_loading": "low",
    }

    analyzed = sum(1 for p in pages.values() if p.get("status") == 200)
    summary = {
        "pages_analyzed": analyzed,
        "psi_enabled": data.get("psi_enabled", False),
        "psi_strategy": data.get("psi_strategy"),
        "median_page_weight_bytes": (sorted(weights)[len(weights) // 2] if weights else 0),
        "issues_by_check": {k: len(v) for k, v in findings.items()},
        "issues_by_severity": {
            level: sum(len(findings[k]) for k, s in severity.items() if s == level)
            for level in ("high", "medium", "low")
        },
    }
    report = {"summary": summary, "severity": severity, "findings": findings}

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2, ensure_ascii=False)
    print(json.dumps(summary, indent=2))
    print(f"Full report written to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Explore Other Claude SEO Skills

Redirect Audit Audits a site's redirects for redirect chains and loops, redirects ending in 404s or errors, 302s used for permanent moves, HTTPS-to-HTTP downgrades, client-side redirects, dropped query strings, and canonicals pointing at redirecting URLs. redirect-audit Open Robots.txt Audit Audits a site's robots.txt for a redirected or HTML-served file, a 5xx read as block-everything, a sitewide Disallow, syntax errors, blocked CSS/JS, missing Sitemap directives, unsupported directives, oversized files, and encoding issues. robots-txt-audit Open Sitemap Audit Audits a site's XML sitemaps: discovering them from robots.txt, following sitemap index files, and validating every URL for broken or redirecting targets, noindexed or canonicalized pages, wrong host or protocol, duplicates, oversized sitemaps, and invalid lastmod values. sitemap-audit Open Llms.txt Audit Audits a site's /llms.txt for AI and LLM discoverability: missing files, HTML served instead of markdown, a missing H1 or summary, broken or redirecting links, links missing descriptions, wrong-host or relative URLs, and a companion /llms-full.txt. llms-txt-audit Open Canonical Tag Audit Audits a site's rel=canonical tags for missing canonicals, multiple conflicting canonicals, canonicals pointing at non-200, redirecting, or noindexed URLs, relative or cross-host canonicals, https-to-http downgrades, and Link-header conflicts. canonical-tag-audit Open Content Quality Audit Audits a site's content quality for thin content, exact-duplicate body text across pages, near-duplicate (templated) pages, low text-to-HTML ratio, and pages missing an H1. content-quality-audit Open Keyword Cannibalization Audit Audits a site for keyword cannibalization: pages with duplicate title targets, clusters of pages whose title, H1, and keyword signatures overlap, and pages sharing the same primary keyword phrase, with a recommended hierarchy. keyword-cannibalization-audit Open Mixed Content Audit Audits an HTTPS site for mixed content: active mixed content (http scripts, styles, iframes browsers block), insecure form actions, passive mixed content (http images and media), protocol-relative URLs, and pages still served over http. mixed-content-audit Open Open Graph Audit Audits a site's Open Graph and Twitter Card metadata: missing og:title, og:description, og:image, or og:url, broken or relative og:image URLs, og:url that mismatches the canonical, missing twitter:card, over-length social titles, and reused default images. open-graph-audit Open Pagination Audit Audits a site's pagination: paginated pages canonicalizing to page 1 and hiding deep content, noindexed component pages, paginated pages missing a self-canonical, broken or inconsistent rel=next/prev, and redundant ?page=1 first pages. pagination-audit Open Soft 404 Audit Audits a site for soft 404s: error pages served with a 200 status, servers that return 200 for every unknown URL, thin pages matching the site's error template, and empty or near-empty pages. soft-404-audit Open Full SEO Audit The orchestrator skill that runs every individual audit below and consolidates the findings into one prioritized, cross-area report ranked by impact, for a complete technical-SEO review in a single pass. full-seo-audit Open Internal Link Audit Audits a site's internal linking structure for broken links, orphan pages, redirect chains, click depth, and anchor text quality. internal-link-audit Open External Link Audit Audits a site's external links for broken targets, redirects, insecure HTTP links, missing rel attributes on affiliate links, and unsafe target="_blank" usage. external-link-audit Open Meta Data Audit Audits a site's title tags and meta descriptions for missing, duplicate, too-long, too-short, or multiple tags, generic boilerplate titles, and keyword stuffing. meta-data-audit Open Schema Markup Audit Audits a site's structured data: extracting JSON-LD, Microdata, and RDFa, then validating it against schema.org and Google rich result requirements. schema-markup-audit Open Site Architecture Audit Audits a site's architecture: hostname and protocol canonicalization, robots.txt, XML sitemap parity, crawl and index directive conflicts, URL structure, click depth, and directory taxonomy. site-architecture-audit Open Heading Structure Audit Audits a site's heading hierarchy for missing or multiple H1s, skipped heading levels, empty headings, headings that don't start at H1, overly long headings, and duplicate heading text. heading-structure-audit Open Image SEO Audit Audits a site's images for SEO and Core Web Vitals: missing alt text, absent width/height, missing lazy-loading, oversized files, legacy formats, generic filenames, and broken images. image-seo-audit Open