#!/usr/bin/env python3
"""Static-site auditor for `website/` — correctness · SEO · a11y · perf.
Pure standard library (`html.parser`, `json`, `xml`, `re`). Walks every HTML page under the
site root, resolves every internal link/asset against the filesystem, and cross-checks the SEO
surfaces (titles, canonicals, `sitemap.xml`, `robots.txt`) and accessibility/perf basics.
Findings are graded: **ERROR** fails the run (exit non-zero); **WARN** is advisory. Run it before
building the home.pl package so a broken link or a sitemap drift can never ship.
python -m scripts.website.audit_site [--root website] [--json]
The auditor is deterministic (sorted walk) and never modifies anything.
"""
from __future__ import annotations
import argparse
import json
import posixpath
import re
import sys
from dataclasses import dataclass
from html.parser import HTMLParser
from pathlib import Path
from xml.etree import ElementTree
CANONICAL_HOST = "aureonzorzatechnologies.pl"
CANONICAL_ORIGIN = f"https://{CANONICAL_HOST}"
REQUIRED_ROOT_FILES = ("index.html", "styles.css", "script.js", "robots.txt", "sitemap.xml")
_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp"}
_BIG_RASTER_BYTES = 200 * 1024
_EXTERNAL_SCHEMES = ("http://", "https://", "mailto:", "tel:", "data:", "javascript:")
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class Finding:
level: str # "ERROR" | "WARN"
page: str # site-relative path, e.g. "projects/aioa-core/index.html"
check: str
message: str
def to_dict(self) -> dict:
return {"level": self.level, "page": self.page, "check": self.check, "message": self.message}
class _PageParser(HTMLParser):
"""Collect the elements the audit needs from one HTML page."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.html_lang: str | None = None
self.has_html_tag = False
self.title: str | None = None
self.meta_description: str | None = None
self.meta_robots: str = ""
self.canonical: str | None = None
self.og_url: str | None = None
self.preload_images = 0
self.h1_count = 0
self.has_main_content = False
self.is_js_detail = False # JS-rendered detail page (heading injected at runtime)
self.imgs: list[dict] = [] # {src, alt(has/empty), loading, decoding}
self.anchors: list[dict] = [] # {href, target, rel}
self.asset_refs: list[str] = [] # link/script/source src|href (+ img src, srcset firsts)
self.jsonld: list[str] = []
self._capture: str | None = None # "title" | "h1" | "jsonld"
self._buf: list[str] = []
# -- helpers ------------------------------------------------------------
@staticmethod
def _attr(attrs: list[tuple[str, str | None]], name: str) -> str | None:
for k, v in attrs:
if k == name:
return v if v is not None else ""
return None
def _has_attr(self, attrs, name) -> bool:
return any(k == name for k, _ in attrs)
# -- parser hooks -------------------------------------------------------
def handle_starttag(self, tag: str, attrs) -> None:
if tag == "html":
self.has_html_tag = True
self.html_lang = self._attr(attrs, "lang")
elif tag == "title":
self._capture, self._buf = "title", []
elif tag == "h1":
self.h1_count += 1
self._capture, self._buf = "h1", []
elif tag == "meta":
name = (self._attr(attrs, "name") or "").lower()
prop = (self._attr(attrs, "property") or "").lower()
content = self._attr(attrs, "content") or ""
if name == "description":
self.meta_description = content
elif name == "robots":
self.meta_robots = content.lower()
elif prop == "og:url":
self.og_url = content
elif tag == "link":
rel = (self._attr(attrs, "rel") or "").lower()
href = self._attr(attrs, "href") or ""
if rel == "canonical":
self.canonical = href
if rel == "preload" and (self._attr(attrs, "as") or "") == "image":
self.preload_images += 1
if rel in {"stylesheet", "icon", "preload", "apple-touch-icon"} and href:
self.asset_refs.append(href)
elif tag == "script":
src = self._attr(attrs, "src")
if src:
self.asset_refs.append(src)
if (self._attr(attrs, "type") or "").lower() == "application/ld+json":
self._capture, self._buf = "jsonld", []
elif tag == "img":
src = self._attr(attrs, "src") or ""
self.imgs.append(
{
"src": src,
"has_alt": self._has_attr(attrs, "alt"),
"alt": self._attr(attrs, "alt") or "",
"loading": self._attr(attrs, "loading"),
"decoding": self._attr(attrs, "decoding"),
}
)
if src:
self.asset_refs.append(src)
srcset = self._attr(attrs, "srcset")
if srcset:
for part in srcset.split(","):
url = part.strip().split(" ")[0].strip()
if url:
self.asset_refs.append(url)
elif tag == "source":
for key in ("src", "srcset"):
val = self._attr(attrs, key)
if val:
self.asset_refs.append(val.split(",")[0].strip().split(" ")[0].strip())
elif tag == "a":
self.anchors.append(
{
"href": self._attr(attrs, "href") or "",
"target": (self._attr(attrs, "target") or "").lower(),
"rel": (self._attr(attrs, "rel") or "").lower(),
}
)
if (self._attr(attrs, "id") or "") == "main-content":
self.has_main_content = True
# JS-rendered detail pages inject their
at runtime from data/*.json
if self._has_attr(attrs, "data-project-detail"):
self.is_js_detail = True
if tag == "body" and self._has_attr(attrs, "data-project-slug"):
self.is_js_detail = True
def handle_endtag(self, tag: str) -> None:
if self._capture and tag in {"title", "h1", "script"}:
text = "".join(self._buf).strip()
if self._capture == "title" and tag == "title":
self.title = text
elif self._capture == "h1" and tag == "h1":
pass # count only
elif self._capture == "jsonld" and tag == "script":
self.jsonld.append(text)
self._capture, self._buf = None, []
def handle_data(self, data: str) -> None:
if self._capture:
self._buf.append(data)
# ---------------------------------------------------------------------------
# link / asset resolution
# ---------------------------------------------------------------------------
def _strip_target(href: str) -> str:
return href.split("#", 1)[0].split("?", 1)[0].strip()
def _is_external(href: str) -> bool:
low = href.lower()
return low.startswith(_EXTERNAL_SCHEMES) or low.startswith("//")
def _resolve(root: Path, page: Path, href: str) -> Path | None:
"""Resolve an internal href/src to a filesystem path (or None if it exists as-is/dir)."""
target = _strip_target(href)
if not target:
return None
if target.startswith("/"):
base = posixpath.normpath(target.lstrip("/"))
fs = root / base
else:
page_dir = page.parent.relative_to(root).as_posix()
joined = posixpath.normpath(posixpath.join(page_dir, target))
fs = root / joined
# directory-style link → its index.html
if href.rstrip().endswith("/") or (fs.suffix == "" and not fs.is_file()):
return fs / "index.html"
return fs
def _page_url(root: Path, page: Path) -> str:
rel = page.relative_to(root).as_posix()
if page.name == "index.html":
parent = posixpath.dirname(rel)
return f"{CANONICAL_ORIGIN}/" if not parent else f"{CANONICAL_ORIGIN}/{parent}/"
return f"{CANONICAL_ORIGIN}/{rel}"
# ---------------------------------------------------------------------------
# audit
# ---------------------------------------------------------------------------
def audit(root: Path) -> list[Finding]:
"""Audit the static site at ``root`` and return all findings (sorted, deterministic)."""
root = root.resolve()
findings: list[Finding] = []
def add(level: str, page: Path | str, check: str, message: str) -> None:
page_str = page if isinstance(page, str) else page.relative_to(root).as_posix()
findings.append(Finding(level, page_str, check, message))
# required root files
for name in REQUIRED_ROOT_FILES:
if not (root / name).is_file():
add("ERROR", name, "root-file", f"required root file missing: {name}")
# data JSON validity
data_dir = root / "data"
if data_dir.is_dir():
for jf in sorted(data_dir.glob("*.json")):
try:
json.loads(jf.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
add("ERROR", jf, "json", f"invalid JSON: {exc}")
# CSS url(...) asset resolution
for css in sorted(root.rglob("*.css")):
text = css.read_text(encoding="utf-8", errors="replace")
for m in re.finditer(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)", text):
ref = m.group(1).strip()
if _is_external(ref) or ref.startswith("data:"):
continue
resolved = _resolve(root, css, ref)
if resolved is not None and not resolved.exists():
add("ERROR", css, "css-asset", f"missing asset referenced in CSS: {ref}")
pages = sorted(root.rglob("*.html"))
indexable_urls: set[str] = set()
for page in pages:
parser = _PageParser()
try:
parser.feed(page.read_text(encoding="utf-8", errors="replace"))
except Exception as exc: # noqa: BLE001
add("ERROR", page, "parse", f"HTML parse failed: {exc}")
continue
noindex = "noindex" in parser.meta_robots
# --- a11y ----------------------------------------------------------
if not parser.html_lang:
add("ERROR", page, "a11y-lang", " is missing a non-empty lang attribute")
for img in parser.imgs:
if not img["has_alt"]:
add("ERROR", page, "a11y-img-alt", f" without alt attribute: {img['src'] or '(no src)'}")
if not parser.is_js_detail and parser.h1_count != 1:
add("WARN", page, "a11y-h1", f"expected exactly one
, found {parser.h1_count}")
has_skip_to_main = any(a["href"].strip() == "#main-content" for a in parser.anchors)
if has_skip_to_main and not parser.has_main_content:
add(
"ERROR",
page,
"a11y-skip-target",
'skip link targets #main-content but no element has id="main-content"',
)
# --- links / assets ------------------------------------------------
for a in parser.anchors:
href = a["href"]
if not href or _is_external(href):
if a["target"] == "_blank" and "noopener" not in a["rel"]:
add(
"ERROR", page, "a11y-noopener", f'target="_blank" link without rel="noopener": {href}'
)
continue
if href.startswith("#"):
continue
resolved = _resolve(root, page, href)
if resolved is not None and not resolved.exists():
add("ERROR", page, "dead-link", f"internal link resolves to a missing file: {href}")
for ref in parser.asset_refs:
if not ref or _is_external(ref) or ref.startswith("#"):
continue
resolved = _resolve(root, page, ref)
if resolved is not None and not resolved.exists():
add("ERROR", page, "dead-asset", f"asset reference resolves to a missing file: {ref}")
# --- perf ----------------------------------------------------------
if parser.preload_images > 1:
add(
"WARN",
page,
"perf-preload",
f"{parser.preload_images} image preloads (keep to the hero only)",
)
for img in parser.imgs:
src = _strip_target(img["src"])
if not src or _is_external(src):
continue
# lazy-loading advice is for raster images; SVGs are vector (tiny) and heroes
# should stay eager, so only flag raster s that opt out of loading hints.
if not img["loading"] and posixpath.splitext(src)[1].lower() in _RASTER_SUFFIXES | {".webp"}:
add("WARN", page, "perf-loading", f"raster without loading attribute: {src}")
resolved = _resolve(root, page, src)
if (
resolved
and resolved.suffix.lower() in _RASTER_SUFFIXES
and resolved.is_file()
and resolved.stat().st_size > _BIG_RASTER_BYTES
):
kb = resolved.stat().st_size // 1024
add("WARN", page, "perf-image", f"non-WebP raster {src} is {kb} KB (consider WebP)")
# --- JSON-LD -------------------------------------------------------
for i, block in enumerate(parser.jsonld):
try:
json.loads(block)
except Exception as exc: # noqa: BLE001
add("ERROR", page, "jsonld", f"JSON-LD block #{i + 1} is invalid: {exc}")
# --- SEO -----------------------------------------------------------
if noindex:
continue # noindex pages are exempt from indexable SEO checks + sitemap
if page.name == "index.html":
indexable_urls.add(_page_url(root, page))
if not parser.title:
add("ERROR", page, "seo-title", "missing or empty ")
if not parser.meta_description:
add("ERROR", page, "seo-description", "missing or empty ")
if not parser.canonical:
add("ERROR", page, "seo-canonical", "missing ")
elif CANONICAL_HOST not in parser.canonical:
add(
"ERROR",
page,
"seo-canonical-host",
f"canonical host is not {CANONICAL_HOST}: {parser.canonical}",
)
if parser.og_url and parser.canonical and parser.og_url != parser.canonical:
add(
"ERROR",
page,
"seo-og-url",
f"og:url ({parser.og_url}) does not match canonical ({parser.canonical})",
)
# --- sitemap / robots cross-check --------------------------------------
sitemap = root / "sitemap.xml"
if sitemap.is_file():
try:
tree = ElementTree.fromstring(sitemap.read_text(encoding="utf-8"))
ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
locs = {el.text.strip() for el in tree.findall(".//sm:loc", ns) if el.text}
except Exception as exc: # noqa: BLE001
add("ERROR", "sitemap.xml", "sitemap-parse", f"could not parse sitemap: {exc}")
locs = set()
for url in sorted(locs):
if not url.startswith("https://"):
add("ERROR", "sitemap.xml", "sitemap-abs", f"sitemap URL is not absolute HTTPS: {url}")
if CANONICAL_HOST not in url:
add(
"ERROR", "sitemap.xml", "sitemap-host", f"sitemap URL host is not {CANONICAL_HOST}: {url}"
)
for missing in sorted(indexable_urls - locs):
add("ERROR", "sitemap.xml", "sitemap-missing", f"indexable page not in sitemap: {missing}")
for extra in sorted(locs - indexable_urls):
add(
"ERROR",
"sitemap.xml",
"sitemap-extra",
f"sitemap lists a URL with no matching indexable page: {extra}",
)
robots = root / "robots.txt"
if robots.is_file():
text = robots.read_text(encoding="utf-8")
want = f"{CANONICAL_ORIGIN}/sitemap.xml"
sitemap_lines = [ln.strip() for ln in text.splitlines() if ln.strip().lower().startswith("sitemap:")]
if not sitemap_lines:
add("ERROR", "robots.txt", "robots-sitemap", "robots.txt has no Sitemap: line")
elif not any(ln.split(":", 1)[1].strip() == want for ln in sitemap_lines):
add("ERROR", "robots.txt", "robots-sitemap", f"robots.txt Sitemap: line does not point to {want}")
return sorted(findings, key=lambda f: (f.level != "ERROR", f.page, f.check, f.message))
def _print_report(findings: list[Finding]) -> None:
errors = [f for f in findings if f.level == "ERROR"]
warns = [f for f in findings if f.level == "WARN"]
by_page: dict[str, list[Finding]] = {}
for f in findings:
by_page.setdefault(f.page, []).append(f)
print("Static-site audit - website/ (correctness | SEO | a11y | perf)")
for page in sorted(by_page):
print(f"\n {page}")
for f in by_page[page]:
mark = "x" if f.level == "ERROR" else "-"
print(f" {mark} [{f.level}] {f.check}: {f.message}")
print(f"\n {len(errors)} error(s) | {len(warns)} warning(s)")
if not findings:
print(" clean")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Audit the static website/ site (correctness | SEO | a11y | perf)."
)
parser.add_argument("--root", default=None, help="site root (default: /website)")
parser.add_argument("--json", action="store_true", help="emit findings as JSON")
args = parser.parse_args(argv)
root = Path(args.root) if args.root else _repo_root() / "website"
if not root.is_dir():
print(f"site root not found: {root}", file=sys.stderr)
return 2
findings = audit(root)
if args.json:
print(json.dumps([f.to_dict() for f in findings], indent=2))
else:
_print_report(findings)
return 1 if any(f.level == "ERROR" for f in findings) else 0
if __name__ == "__main__": # pragma: no cover - manual entry point
raise SystemExit(main())