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
altattribute, 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/heightattributes 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 likeIMG_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.
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
alttext - 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:
- 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. - Scope — full site, a specific section (e.g.,
/blog/), or a list of URLs. - Crawl limits — max pages (default 500) for live crawls.
- File checks — whether to fetch each image to measure byte size and detect broken images (the
--check-filesflag; slower but enables the oversized-file and broken-image checks). - 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
.htmlfiles 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:
| Check | Severity |
|---|---|
Missing alt attribute | High |
| 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> source | Low |
| Generic filename | Low |
| Alt text too long (> 125 chars) | Low |
| Redundant alt prefix ("image of…") | Low |
Empty alt="" (decorative — confirm intent) | Informational |
| Duplicate alt reused across many images | Informational |
Step 4: Produce the report
Write a report following the structure in references/report-template.md:
- Summary — pages crawled, images found, alt-text coverage %, issue counts by severity
- Issues — one section per failing check, listing the page, the image, and a specific fix
- 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/heightattributes (or CSSaspect-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
widthandheightto the 6 product thumbnails on/shopto 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 checkreferences/report-template.md— report output structurescripts/extract_images.py— crawl a site and build the image inventory (add--check-filesfor 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.
Image SEO Audit Checks
Full definitions, thresholds, and rationale for each audit check.
High severity
1. Missing alt attribute
- Definition: An
<img>with noaltattribute at all (distinct fromalt=""). - 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
srcthat 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 awidthand/orheightattribute. - 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
widthandheightattributes matching the image's intrinsic aspect ratio (CSS can still scale it responsively), or set a CSSaspect-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
srcsetso 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 byAcceptheader.
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 CSSbackground-imageare 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.
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 intentionalalt=""(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.
#!/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.
#!/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()