Claude AI

Image SEO Audit Claude Skill

This skill audits a website's images for SEO and Core Web Vitals compliance and produces an actionable report. It crawls pages to find missing or empty alt text, absent width and height attributes, missing lazy-loading, oversized files and legacy formats, generic filenames, and broken image URLs, then recommends concrete fixes for each one.

What The Skill Checks

  • High-severity failures: images with no alt attribute, which removes the primary accessibility signal and the main way search engines understand image content, and broken images returning a non-200 status when file checking is enabled.
  • Medium-severity performance issues: missing width/height attributes that cause Cumulative Layout Shift, a Core Web Vitals ranking factor, and oversized files above the 200 KB default budget that slow Largest Contentful Paint.
  • Low-severity and informational signals: below-fold images missing loading="lazy", legacy JPEG/PNG/GIF formats with no WebP or AVIF source, generic filenames like IMG_1234, alt text over 125 characters or with redundant "image of" prefixes, plus decorative empty alt, duplicate alt text, and CSS background images the crawler cannot see.

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 extract every image from pages and audit the resulting inventory. 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 image-seo-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 whether to fetch images for byte-size validation), the audit workflow, and the resources the skill relies on.

SKILL.mdView on GitHub
name
image-seo-audit
description
Audit a website's images for SEO and Core Web Vitals. Crawls pages to find images with missing or empty alt text, missing width/height (layout shift), no lazy loading, oversized files, legacy formats (no WebP/AVIF), generic filenames, and broken image URLs. Use when the user asks to audit, analyze, check, or improve image SEO, alt text, image accessibility, image file sizes, image formats, or image loading performance of a website.

Image SEO Audit

Audit the images of a website and produce an actionable SEO and performance report.

When to use this skill

Use this skill when the user asks to:

  • Audit or analyze the images on a website for SEO or accessibility
  • Find images with missing, empty, or poor alt text
  • Find images that cause layout shift (missing width/height) or load slowly (no lazy loading, oversized files)
  • Identify images served in legacy formats instead of WebP/AVIF
  • Find broken images or generic, uninformative filenames
Inputs to collect

Before starting, confirm with the user:

  1. Site URL or local files — a live site root URL (e.g., https://example.com), a sitemap URL, a list of URLs, or a local folder of HTML files.
  2. Scope — full site, a specific section (e.g., /blog/), or a list of URLs.
  3. Crawl limits — max pages (default 500) for live crawls.
  4. File checks — whether to fetch each image to measure byte size and detect broken images (the --check-files flag; slower but enables the oversized-file and broken-image checks).
  5. Size budget — the byte threshold above which an image is "oversized" (default 200 KB).
Workflow
Step 1: Gather the page set and extract images
  • If a sitemap is available (/sitemap.xml), it is used to get the canonical page list.
  • Otherwise, the crawler starts from the homepage following only same-host links.
  • For local HTML folders, all .html files are enumerated.

Use scripts/extract_images.py to build an image inventory:

python3 scripts/extract_images.py https://example.com --max-pages 500 --check-files --output image_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_images.py --from-cache page_cache.json --check-files --output image_inventory.json

For every <img> the inventory records: absolute src, alt (null if the attribute is absent vs. empty string if alt=""), width/height attributes, loading, whether it has a srcset, whether it sits inside a <picture>, and its position in the page. With --check-files, each unique image URL is HEAD-requested to record HTTP status, content type, and byte size.

Step 2: Run the audit checks

Use scripts/audit_images.py to run all audit checks against the inventory:

python3 scripts/audit_images.py image_inventory.json --max-bytes 200000 --output audit_report.json

The oversized-file and broken-image checks only run if the inventory was built with --check-files.

Step 3: Evaluate the audit checks

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

CheckSeverity
Missing alt attributeHigh
Broken image (non-200 URL)High
Missing width/height (layout shift)Medium
Oversized file (> size budget)Medium
Not lazy-loaded (below the fold)Low
Legacy format with no modern <picture> sourceLow
Generic filenameLow
Alt text too long (> 125 chars)Low
Redundant alt prefix ("image of…")Low
Empty alt="" (decorative — confirm intent)Informational
Duplicate alt reused across many imagesInformational
Step 4: Produce the report

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

  1. Summary — pages crawled, images found, alt-text coverage %, issue counts by severity
  2. Issues — one section per failing check, listing the page, the image, and a specific fix
  3. Prioritized action list — ordered by severity and estimated impact
Step 5: Recommend fixes

For each issue, give a concrete, copy-pasteable recommendation. When drafting alt text:

  • Describe what the image shows and its purpose on the page, in roughly 5–15 words
  • Never start with "image of" or "picture of" — screen readers already announce it as an image
  • Use alt="" only for purely decorative images; never invent details not visible in the image
  • For dimensions, recommend the exact width/height attributes (or CSS aspect-ratio) that match the rendered size
  • For oversized or legacy images, recommend compressing and serving WebP/AVIF via <picture> with a fallback

Example recommendations:

  • Add alt text to the hero image on /: alt="Team of nurses reviewing a patient chart at a bedside"
  • Add width and height to the 6 product thumbnails on /shop to stop layout shift
  • Convert /images/hero.png (1.4 MB) to WebP and serve it through <picture>; this image loads on every page
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, and rationale for every audit check
  • references/report-template.md — report output structure
  • scripts/extract_images.py — crawl a site and build the image inventory (add --check-files for size/format checks)
  • scripts/audit_images.py — run audit checks against an image inventory JSON file

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

Full definitions, thresholds, and rationale for every audit check, from missing alt text and broken images to oversized files, legacy formats, and redundant alt prefixes.

references/audit-checks.mdView on GitHub

Image SEO Audit Checks

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

High severity
1. Missing alt attribute
  • Definition: An <img> with no alt attribute at all (distinct from alt="").
  • Why it matters: Alt text is the primary accessibility signal for screen-reader users and the main way search engines understand image content (and rank it in image search). A missing attribute is also an accessibility (WCAG) failure.
  • Fix: Add concise, descriptive alt text that conveys the image's content and function. If the image is purely decorative, use alt="" intentionally so assistive tech skips it.
2. Broken image
  • Definition: An image src that returns a non-200 HTTP status (404, 403, 5xx) or fails to connect. Requires the inventory to be built with --check-files.
  • Why it matters: Broken images degrade UX, waste crawl budget, and signal a poorly maintained page.
  • Fix: Restore the asset, correct the path, or remove the reference.
Medium severity
3. Missing width/height
  • Definition: An <img> lacking a width and/or height attribute.
  • Why it matters: Without intrinsic dimensions the browser cannot reserve space before the image loads, causing Cumulative Layout Shift (CLS) — a Core Web Vitals metric and a ranking factor.
  • Fix: Add width and height attributes matching the image's intrinsic aspect ratio (CSS can still scale it responsively), or set a CSS aspect-ratio.
4. Oversized file
  • Definition: An image whose byte size exceeds the budget (default 200 KB). Requires --check-files.
  • Why it matters: Large images are the most common cause of slow Largest Contentful Paint (LCP) and heavy mobile data use.
  • Fix: Compress, resize to the largest rendered dimension, and serve a modern format. Use responsive srcset so small screens download small images.
Low severity
5. Not lazy-loaded
  • Definition: An image at or beyond the configured position (default: any image after the first) that lacks loading="lazy".
  • Why it matters: Eagerly loading off-screen images delays the loading of content the user can actually see.
  • Caveat: The first, above-the-fold / LCP image should not be lazy-loaded (it delays LCP). The check skips the first image by default; confirm which images are above the fold before applying fixes.
  • Fix: Add loading="lazy" to below-the-fold images; leave the LCP image eager.
6. Legacy format with no modern source
  • Definition: A JPEG, PNG, or GIF served by a plain <img> not wrapped in a <picture> that offers a modern source.
  • Why it matters: WebP and AVIF are typically 25–50% smaller at equivalent quality, improving LCP and bandwidth.
  • Fix: Serve WebP/AVIF via <picture> with a legacy <img> fallback, or have the CDN/image pipeline negotiate format by Accept header.
7. Generic filename
  • Definition: Uninformative filenames such as IMG_1234, DSC0001, screenshot, untitled, download, all-digit, or hash-like names.
  • Why it matters: Filenames are a minor but real relevance signal for image search; descriptive names help.
  • Fix: Rename to short, hyphenated, descriptive filenames (e.g., red-running-shoe-side.webp).
8. Alt text too long
  • Definition: Alt text longer than 125 characters.
  • Why it matters: Some screen readers truncate around this length, and very long alt usually means description belongs in body copy or a caption.
  • Fix: Tighten to the essential content; move extended detail to a visible caption or figcaption.
9. Redundant alt prefix
  • Definition: Alt text beginning with "image of", "picture of", "photo of", or similar.
  • Why it matters: Assistive tech already announces the element as an image, so the prefix is redundant noise.
  • Fix: Remove the prefix and describe the subject directly.
Informational (note, do not count as issues)
  • Empty alt (alt="") — valid and correct for decorative images, but listed so the user can confirm each one is genuinely decorative and not an accidental empty value on a meaningful image.
  • Duplicate alt text — the same non-empty alt reused across three or more distinct images. Sometimes legitimate (repeated logo), but often a templating bug that applies one description to many different images.
  • Background images in CSS — this audit only sees <img> and <picture> elements; images set via CSS background-image are invisible to crawlers and to image search. Note this limitation if visual images appear missing from the inventory.

Report Template (references/report-template.md)

The output structure the skill follows when writing the final audit report, including summary statistics and prioritized fixes.

references/report-template.mdView on GitHub

Image SEO Audit Report Template

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

# Image SEO Audit — {site}

**Date:** {date}
**Scope:** {scope description}
**Pages crawled:** {n} | **Images found:** {n} | **Alt-text coverage:** {pct}%

## Summary

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

{2–3 sentence overview of the site's image health and the biggest wins.}

## High-severity issues

### Missing alt text ({n})

| Page | Image | Suggested alt |
|---|---|---|
| ... | ... | ... |

### Broken images ({n})

| Page | Image | Status |
|---|---|---|
| ... | ... | ... |

## Medium-severity issues

### Missing dimensions ({n})

| Page | Image | Suggested width × height |
|---|---|---|
| ... | ... | ... |

### Oversized files ({n})

| Page | Image | Size | Suggested action |
|---|---|---|---|
| ... | ... | ... | ... |

## Low-severity issues

{One subsection per failing check: not lazy-loaded, legacy format, generic filename, alt too long, redundant alt. Same table style: page, image, current value, suggested fix.}

## Informational

{Empty alt and duplicate alt — list for confirmation, not as defects.}

## Prioritized action list

1. {Highest-impact fix, with exact pages/images and ready-to-apply values}
2. ...
Guidance
  • Every issue row must name the exact page URL and image src, plus a concrete fix — never generic advice.
  • Suggested alt text must describe the image's actual visible content and purpose; never invent details you cannot see. If you cannot determine what an image shows from the page context, say so and ask, rather than guessing.
  • Distinguish missing alt (a real defect) from intentional alt="" (decorative) in the report.
  • For lazy-loading recommendations, explicitly exempt the LCP / first above-the-fold image.
  • Cap tables at ~20 rows in the report body; attach or offer the full list (CSV/JSON) if larger.
  • Group templated issues (e.g., every product thumbnail missing dimensions from one template) as a single template-level fix with a few example URLs.
  • Order the action list by severity first, then by reach (images that appear site-wide or on high-traffic pages first).
  • Note the background-image limitation if the user expected images that a crawler cannot see.

Image Extractor Script (scripts/extract_images.py)

Crawls a live site, file list, or local HTML folder within the configured page limits and collects every image with its src, alt, dimensions, loading attribute, and file size when --check-files is used.

scripts/extract_images.pyView on GitHub
#!/usr/bin/env python3
"""Crawl a website (or scan local HTML files) and build an image inventory as JSON.

Usage:
    python3 extract_images.py https://example.com [--max-pages 500] [--check-files]
        [--output image_inventory.json]
    python3 extract_images.py ./site-folder --local [--output image_inventory.json]
    python3 extract_images.py urls.txt --url-list [--output image_inventory.json]

With --check-files the script also issues a lightweight request per unique image URL
to record HTTP status, content type, and byte size (needed for the broken-image,
oversized-file, and format checks).

Output JSON schema:
{
  "site": "https://example.com",
  "checked_files": true,
  "pages": {
    "<url>": {
      "status": 200,
      "images": [
        {
          "src": "<absolute url or null>",
          "alt": "<string, or null if the attribute is absent>",
          "width": "<attr value or null>",
          "height": "<attr value or null>",
          "loading": "<attr value or null>",
          "has_srcset": false,
          "in_picture": false,
          "position": 0            # 0-based order within the page
        }
      ]
    }
  },
  "files": {                       # only present with --check-files
    "<image url>": {"status": 200, "content_type": "image/webp", "bytes": 12345}
  }
}

Uses only the Python standard library.
"""

import argparse
import json
import os
import sys
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 = "ImageSeoAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15


class ImageParser(HTMLParser):
    """Extract <img> elements (with attributes), <picture> context, and same-host links."""

    def __init__(self, base_url):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.images = []
        self.links = []
        self._picture_depth = 0
        self._position = 0

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "picture":
            self._picture_depth += 1
        elif tag == "img":
            src = a.get("src") or a.get("data-src")
            self.images.append({
                "src": urllib.parse.urljoin(self.base_url, src.strip()) if src else None,
                "alt": a["alt"] if "alt" in a else None,
                "width": a.get("width"),
                "height": a.get("height"),
                "loading": (a.get("loading") or "").lower() or None,
                "has_srcset": bool(a.get("srcset") or a.get("data-srcset")),
                "in_picture": self._picture_depth > 0,
                "position": self._position,
            })
            self._position += 1
        elif tag == "a" and a.get("href"):
            href = a["href"].strip()
            if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
                target = urllib.parse.urljoin(self.base_url, href)
                self.links.append(urllib.parse.urldefrag(target)[0])

    def handle_endtag(self, tag):
        if tag == "picture" and self._picture_depth > 0:
            self._picture_depth -= 1


def fetch(url):
    """Return (status, final_url, html_or_none)."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            ctype = resp.headers.get("Content-Type", "")
            body = None
            if "html" in ctype or "xml" in ctype:
                body = resp.read(2_000_000).decode("utf-8", errors="replace")
            return resp.status, resp.geturl(), body
    except urllib.error.HTTPError as e:
        return e.code, url, None
    except Exception:
        return 0, url, None  # 0 = network/connection error


def head(url):
    """Return (status, content_type, byte_size) for an image URL using a HEAD request."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, method="HEAD")
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            length = resp.headers.get("Content-Length")
            return resp.status, resp.headers.get("Content-Type", "") or "", int(length) if length else None
    except urllib.error.HTTPError as e:
        return e.code, "", None
    except Exception:
        return 0, "", None


def norm_host(url):
    host = urllib.parse.urlparse(url).netloc.lower()
    return host[4:] if host.startswith("www.") else host


def pages_from_sitemap(site):
    status, _final, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
    if status != 200 or not body:
        return []
    urls = []
    try:
        root = ET.fromstring(body)
        for loc in root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc"):
            if loc.text:
                urls.append(loc.text.strip())
    except ET.ParseError:
        return []
    return urls


def parse_page(url, html):
    parser = ImageParser(url)
    try:
        parser.feed(html)
    except Exception:
        pass
    return {"images": parser.images}, parser.links


def extract_from_cache(cache_path):
    """Pure: turn a shared page cache (from fetch_pages.py) into an image inventory.

    No network — parses the cached HTML for exactly the fields this audit needs.
    (The optional --check-files image HEAD-probe still hits the network if asked.)
    """
    with open(cache_path, encoding="utf-8") as f:
        cache = json.load(f)
    pages = {}
    for url, page in cache.get("pages", {}).items():
        html = page.get("html")
        status = page.get("status", 0)
        if not html:
            pages[url] = {"status": status, "images": []}
            continue
        info, _links = parse_page(page.get("final_url") or url, html)
        info["status"] = status
        pages[url] = info
    return cache.get("site", cache_path), pages


def crawl(site, max_pages):
    start = site.rstrip("/")
    host = norm_host(start)
    seeds = pages_from_sitemap(start) or [start]
    queue = deque(seeds[:max_pages])
    seen = set(queue)
    pages = {}
    while queue and len(pages) < max_pages:
        url = queue.popleft()
        status, final_url, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = {"status": status, "images": []}
            continue
        info, links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
        for link in links:
            if norm_host(link) == host and link not in seen:
                seen.add(link)
                queue.append(link)
    return pages


def scan_local(folder):
    pages = {}
    for root, _dirs, files in os.walk(folder):
        for name in files:
            if name.lower().endswith((".html", ".htm")):
                path = os.path.join(root, name)
                with open(path, encoding="utf-8", errors="replace") as f:
                    html = f.read()
                info, _links = parse_page("file://" + os.path.abspath(path), html)
                info["status"] = 200
                pages[os.path.relpath(path, folder)] = info
    return pages


def scan_url_list(path, max_pages):
    pages = {}
    with open(path, encoding="utf-8") as f:
        urls = [line.strip() for line in f if line.strip() and not line.startswith("#")]
    for url in urls[:max_pages]:
        status, final_url, html = fetch(url)
        print(f"[{status}] {url}", file=sys.stderr)
        if html is None:
            pages[url] = {"status": status, "images": []}
            continue
        info, _links = parse_page(final_url, html)
        info["status"] = status
        pages[url] = info
    return pages


def check_files(pages):
    files = {}
    unique = set()
    for page in pages.values():
        for img in page.get("images", []):
            if img.get("src") and img["src"].startswith(("http://", "https://")):
                unique.add(img["src"])
    for i, src in enumerate(sorted(unique), 1):
        status, ctype, size = head(src)
        files[src] = {"status": status, "content_type": ctype, "bytes": size}
        print(f"  file {i}/{len(unique)} [{status}] {src}", file=sys.stderr)
    return files


def main():
    ap = argparse.ArgumentParser(description="Build an image inventory for a site.")
    ap.add_argument("source", nargs="?",
                    help="Site root URL, local folder (--local), or URL list file (--url-list)")
    ap.add_argument("--from-cache",
                    help="Extract from a shared page cache produced by fetch_pages.py (no network)")
    ap.add_argument("--max-pages", type=int, default=500)
    ap.add_argument("--output", default="image_inventory.json")
    ap.add_argument("--local", action="store_true", help="Treat source as a local folder of HTML files")
    ap.add_argument("--url-list", action="store_true", help="Treat source as a text file of URLs")
    ap.add_argument("--check-files", action="store_true",
                    help="HEAD-request each image URL to record status, type, and byte size")
    args = ap.parse_args()

    if args.from_cache:
        site, pages = extract_from_cache(args.from_cache)
    elif not args.source:
        ap.error("provide a source, or --from-cache page_cache.json")
    elif args.local:
        site, pages = args.source, scan_local(args.source)
    elif args.url_list:
        site, pages = args.source, scan_url_list(args.source, args.max_pages)
    else:
        site, pages = args.source, crawl(args.source, args.max_pages)

    out = {"site": site, "checked_files": bool(args.check_files), "pages": pages}
    if args.check_files:
        out["files"] = check_files(pages)

    with open(args.output, "w", encoding="utf-8") as f:
        json.dump(out, f, indent=2, ensure_ascii=False)
    total_imgs = sum(len(p.get("images", [])) for p in pages.values())
    print(f"Wrote {len(pages)} pages ({total_imgs} images) to {args.output}", file=sys.stderr)


if __name__ == "__main__":
    main()

Image Auditor Script (scripts/audit_images.py)

Runs the audit checks against the image inventory, applying the oversized-file byte threshold and severity classifications, and outputs the structured audit report data.

scripts/audit_images.pyView on GitHub
#!/usr/bin/env python3
"""Audit an image inventory JSON (from extract_images.py) and emit audit findings.

Usage:
    python3 audit_images.py image_inventory.json [--output audit_report.json]
        [--max-bytes 200000] [--lazy-after 1]

Checks implemented (see references/audit-checks.md):
- missing_alt: <img> with no alt attribute at all (high)
- broken_image: image URL returns a non-200 status (high, needs --check-files)
- empty_alt: alt="" — valid only for decorative images, flagged for review (info)
- missing_dimensions: no width and/or height attribute, risking layout shift (medium)
- oversized_file: image larger than --max-bytes (medium, needs --check-files)
- not_lazy_loaded: img beyond --lazy-after on the page without loading="lazy" (low)
- legacy_format: jpg/png/gif served without a modern <picture> source (low)
- generic_filename: uninformative filename (e.g. IMG_1234, screenshot, untitled) (low)
- alt_too_long: alt text over 125 characters (low)
- redundant_alt: alt text starting with "image of"/"photo of"/"picture of" (low)
- duplicate_alt: identical non-empty alt reused across many distinct images (info)

Uses only the Python standard library.
"""

import argparse
import json
import os
import re
import sys
import urllib.parse
from collections import Counter, defaultdict

ALT_MAX = 125
MODERN_FORMATS = {"webp", "avif", "svg"}
LEGACY_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "bmp", "tiff"}
GENERIC_PATTERNS = [
    re.compile(r"^img[\W_]*\d+$", re.I),
    re.compile(r"^image[\W_]*\d*$", re.I),
    re.compile(r"^dsc[\W_]*\d+$", re.I),
    re.compile(r"^dscn[\W_]*\d+$", re.I),
    re.compile(r"^photo[\W_]*\d*$", re.I),
    re.compile(r"^pic[\W_]*\d*$", re.I),
    re.compile(r"^screen ?shot.*$", re.I),
    re.compile(r"^screenshot.*$", re.I),
    re.compile(r"^untitled.*$", re.I),
    re.compile(r"^download.*$", re.I),
    re.compile(r"^[0-9a-f]{16,}$", re.I),      # hash-like names
    re.compile(r"^\d{6,}$"),                     # all-digit names
]
REDUNDANT_PREFIXES = ("image of", "picture of", "photo of", "graphic of", "a picture of")


def filename_stem(src):
    path = urllib.parse.urlparse(src).path
    base = os.path.basename(path)
    stem, _ext = os.path.splitext(base)
    return urllib.parse.unquote(stem)


def extension(src):
    path = urllib.parse.urlparse(src).path
    return os.path.splitext(path)[1].lower().lstrip(".")


def main():
    ap = argparse.ArgumentParser(description="Run image SEO audit checks.")
    ap.add_argument("inventory", help="image_inventory.json from extract_images.py")
    ap.add_argument("--output", default="audit_report.json")
    ap.add_argument("--max-bytes", type=int, default=200_000,
                    help="flag images larger than this many bytes (default 200000)")
    ap.add_argument("--lazy-after", type=int, default=1,
                    help="images at this 0-based position or later should be lazy-loaded")
    args = ap.parse_args()

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

    findings = {
        "missing_alt": [], "broken_image": [], "empty_alt": [],
        "missing_dimensions": [], "oversized_file": [], "not_lazy_loaded": [],
        "legacy_format": [], "generic_filename": [], "alt_too_long": [],
        "redundant_alt": [], "duplicate_alt": [],
    }

    total_images = 0
    with_alt = 0
    alt_to_srcs = defaultdict(set)

    for url, page in pages.items():
        if page.get("status", 200) != 200:
            continue
        for img in page.get("images", []):
            total_images += 1
            src = img.get("src")
            alt = img.get("alt")
            where = {"page": url, "src": src}

            # Alt text
            if alt is None:
                findings["missing_alt"].append(where)
            elif alt.strip() == "":
                findings["empty_alt"].append(where)
            else:
                with_alt += 1
                alt_to_srcs[" ".join(alt.lower().split())].add(src)
                if len(alt) > ALT_MAX:
                    findings["alt_too_long"].append({**where, "alt": alt, "length": len(alt)})
                if alt.lower().lstrip().startswith(REDUNDANT_PREFIXES):
                    findings["redundant_alt"].append({**where, "alt": alt})

            # Dimensions
            if not img.get("width") or not img.get("height"):
                findings["missing_dimensions"].append(where)

            # Lazy loading
            if (img.get("position", 0) >= args.lazy_after
                    and img.get("loading") != "lazy"):
                findings["not_lazy_loaded"].append({**where, "position": img.get("position")})

            # Format / filename (need a real src)
            if src:
                ext = extension(src)
                if ext in LEGACY_EXTENSIONS and not img.get("in_picture"):
                    findings["legacy_format"].append({**where, "format": ext})
                stem = filename_stem(src)
                if stem and any(p.match(stem) for p in GENERIC_PATTERNS):
                    findings["generic_filename"].append({**where, "filename": stem})

            # File-level checks (only when extract was run with --check-files)
            if checked and src in files:
                info = files[src]
                if info.get("status", 0) >= 400 or info.get("status", 0) == 0:
                    findings["broken_image"].append({**where, "status": info.get("status")})
                size = info.get("bytes")
                if size and size > args.max_bytes:
                    findings["oversized_file"].append({**where, "bytes": size})

    # Duplicate alt: same alt text across 3+ distinct image URLs
    for alt, srcs in alt_to_srcs.items():
        if len(srcs) >= 3:
            findings["duplicate_alt"].append({"alt": alt, "count": len(srcs),
                                              "examples": sorted(srcs)[:5]})

    severity = {
        "missing_alt": "high", "broken_image": "high",
        "missing_dimensions": "medium", "oversized_file": "medium",
        "not_lazy_loaded": "low", "legacy_format": "low",
        "generic_filename": "low", "alt_too_long": "low", "redundant_alt": "low",
        "empty_alt": "info", "duplicate_alt": "info",
    }
    summary = {
        "pages_analyzed": sum(1 for p in pages.values() if p.get("status", 200) == 200),
        "images_found": total_images,
        "images_with_alt": with_alt,
        "alt_coverage_pct": round(100 * with_alt / total_images, 1) if total_images else 0,
        "checked_files": checked,
        "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", "info")
        },
    }
    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

Core Web Vitals Audit Audits a site's Core Web Vitals and page-experience signals (LCP, CLS, INP, TTFB), pulling real CrUX field data from PageSpeed Insights and collecting lab proxies like render-blocking resources, page weight, and image hygiene. core-web-vitals-audit Open 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