Spaces:
Running
Running
Download scripts/site_audit.py from siddhm11/ResearchIT: direct link, hf CLI and curl.
- Browser
- Download file 15.3 kB
-
https://huggingface.co/spaces/siddhm11/ResearchIT/resolve/main/scripts/site_audit.py
- Command line
-
hf download hf://spaces/siddhm11/ResearchIT/scripts/site_audit.py
-
curl -L -o site_audit.py https://huggingface.co/spaces/siddhm11/ResearchIT/resolve/main/scripts/site_audit.py
15.3 kB
| """ | |
| Whole-site audit for ResearchIT. Structural, accessibility and hygiene checks | |
| across every user-facing surface, run against a live server. | |
| Deliberately NOT a linter run: the point is to find things that are wrong for a | |
| reader or a crawler, in this specific product, and to separate "actually broken" | |
| from "cosmetically imperfect". | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| import sys | |
| import time | |
| import urllib.request | |
| import urllib.error | |
| from html.parser import HTMLParser | |
| BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:7860" | |
| COOKIE = "arxiv_user_id=feeddemo" | |
| PAGES = [ | |
| ("/", "Feed (redirect or page)"), | |
| ("/onboarding", "Onboarding wizard"), | |
| ("/saved", "Library"), | |
| ("/collections", "Collections index"), | |
| ("/search", "Search (empty)"), | |
| ("/search?q=transformer", "Search (results)"), | |
| ("/api/recommendations", "Feed fragment"), | |
| ] | |
| findings: list[dict] = [] | |
| def add(sev, area, title, detail, where=""): | |
| findings.append({"sev": sev, "area": area, "title": title, | |
| "detail": detail, "where": where}) | |
| def fetch(path, cookie=True): | |
| req = urllib.request.Request(BASE + path, headers={ | |
| "User-Agent": "researchit-audit/1.0", | |
| **({"Cookie": COOKIE} if cookie else {}), | |
| }) | |
| t0 = time.time() | |
| try: | |
| with urllib.request.urlopen(req, timeout=90) as r: | |
| body = r.read().decode("utf-8", "replace") | |
| return r.status, dict(r.headers), body, time.time() - t0 | |
| except urllib.error.HTTPError as e: | |
| return e.code, dict(e.headers), e.read().decode("utf-8", "replace"), time.time() - t0 | |
| except Exception as e: | |
| return 0, {}, f"__ERROR__ {type(e).__name__}: {e}", time.time() - t0 | |
| class Scan(HTMLParser): | |
| """Collect what the checks below need, in one pass.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.headings: list[tuple[int, str]] = [] | |
| self._h = None | |
| self.imgs: list[dict] = [] | |
| self.inputs: list[dict] = [] | |
| self.labels: set[str] = set() | |
| self.links: list[dict] = [] | |
| self.buttons: list[dict] = [] | |
| self.ids: list[str] = [] | |
| self.title = "" | |
| self._in_title = False | |
| self.lang = "" | |
| self.metas: dict[str, str] = {} | |
| self.tabindex: list[str] = [] | |
| self.autofocus = 0 | |
| def handle_starttag(self, tag, attrs): | |
| a = dict(attrs) | |
| if a.get("id"): | |
| self.ids.append(a["id"]) | |
| if a.get("tabindex"): | |
| self.tabindex.append(a["tabindex"]) | |
| if "autofocus" in a: | |
| self.autofocus += 1 | |
| if tag == "html": | |
| self.lang = a.get("lang", "") | |
| if tag == "title": | |
| self._in_title = True | |
| if tag == "meta": | |
| k = a.get("name") or a.get("property") or a.get("charset") | |
| if k: | |
| self.metas[k] = a.get("content", "charset") | |
| if tag in ("h1", "h2", "h3", "h4", "h5", "h6"): | |
| self._h = (int(tag[1]), "") | |
| if tag == "img": | |
| self.imgs.append(a) | |
| if tag in ("input", "textarea", "select"): | |
| self.inputs.append(a) | |
| if tag == "label" and a.get("for"): | |
| self.labels.add(a["for"]) | |
| if tag == "a": | |
| self.links.append(a) | |
| if tag == "button": | |
| self.buttons.append(a) | |
| def handle_endtag(self, tag): | |
| if tag == "title": | |
| self._in_title = False | |
| if tag in ("h1", "h2", "h3", "h4", "h5", "h6") and self._h: | |
| self.headings.append(self._h) | |
| self._h = None | |
| def handle_data(self, d): | |
| if self._in_title: | |
| self.title += d.strip() | |
| if self._h: | |
| self._h = (self._h[0], (self._h[1] + " " + d.strip()).strip()) | |
| # ββ Contrast maths (WCAG 2.x relative luminance) ββββββββββββββββββββββββββββ | |
| def _lin(c): | |
| c = c / 255 | |
| return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 | |
| def lum(hexs): | |
| hexs = hexs.lstrip("#") | |
| if len(hexs) == 3: | |
| hexs = "".join(ch * 2 for ch in hexs) | |
| r, g, b = (int(hexs[i:i + 2], 16) for i in (0, 2, 4)) | |
| return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b) | |
| def ratio(a, b): | |
| la, lb = lum(a), lum(b) | |
| hi, lo = max(la, lb), min(la, lb) | |
| return (hi + 0.05) / (lo + 0.05) | |
| def main(): | |
| print(f"# audit of {BASE}\n") | |
| results = {} | |
| # ββ 1. Reachability, timing, headers ββββββββββββββββββββββββββββββββ | |
| for path, label in PAGES: | |
| st, hdr, body, dt = fetch(path) | |
| results[path] = (st, hdr, body, dt) | |
| if st == 0: | |
| add("critical", "Availability", f"{label} failed to load", | |
| body[:200], path) | |
| continue | |
| if st >= 500: | |
| add("critical", "Availability", f"{label} returned {st}", "", path) | |
| if dt > 5: | |
| add("major", "Performance", f"{label} took {dt:.1f}s", | |
| "Nielsen's limit for keeping user flow is ~1s; past ~10s " | |
| "attention is lost entirely.", path) | |
| elif dt > 2: | |
| add("minor", "Performance", f"{label} took {dt:.1f}s", "", path) | |
| # ββ 2. Per-page HTML structure ββββββββββββββββββββββββββββββββββββββ | |
| for path, label in PAGES: | |
| st, hdr, body, dt = results.get(path, (0, {}, "", 0)) | |
| if st != 200 or body.startswith("__ERROR__"): | |
| continue | |
| frag = path.startswith("/api/") | |
| s = Scan() | |
| try: | |
| s.feed(body) | |
| except Exception as e: | |
| add("major", "Markup", f"{label}: HTML failed to parse", | |
| f"{type(e).__name__}: {e}", path) | |
| continue | |
| if not frag: | |
| if not s.title: | |
| add("major", "SEO", f"{label} has no <title>", "", path) | |
| if not s.lang: | |
| add("major", "A11y", f"{label} has no lang attribute", | |
| "Screen readers need it to pick a pronunciation voice.", path) | |
| if "description" not in s.metas: | |
| add("minor", "SEO", f"{label} has no meta description", "", path) | |
| if "viewport" not in s.metas: | |
| add("critical", "Responsive", f"{label} has no viewport meta", "", path) | |
| # Open Graph / social | |
| if not any(k.startswith("og:") for k in s.metas): | |
| add("minor", "SEO", f"{label} has no Open Graph tags", | |
| "Links shared to Slack/Twitter/Discord render as a bare " | |
| "URL with no title, description or image.", path) | |
| h1s = [h for h in s.headings if h[0] == 1] | |
| if len(h1s) == 0: | |
| add("major", "A11y", f"{label} has no <h1>", | |
| "Heading order: " + str([h[0] for h in s.headings]), path) | |
| elif len(h1s) > 1: | |
| add("minor", "A11y", f"{label} has {len(h1s)} <h1> elements", | |
| str([h[1][:40] for h in h1s]), path) | |
| lv = [h[0] for h in s.headings] | |
| for i in range(1, len(lv)): | |
| if lv[i] - lv[i - 1] > 1: | |
| add("minor", "A11y", f"{label} skips a heading level", | |
| f"h{lv[i-1]} followed by h{lv[i]} β order {lv}", path) | |
| break | |
| for im in s.imgs: | |
| if "alt" not in im: | |
| add("major", "A11y", f"{label}: <img> without alt", | |
| im.get("src", "")[:80], path) | |
| for inp in s.inputs: | |
| if inp.get("type") in ("hidden", "submit", "button"): | |
| continue | |
| has = (inp.get("aria-label") or inp.get("aria-labelledby") | |
| or (inp.get("id") and inp["id"] in s.labels) | |
| or inp.get("title")) | |
| if not has: | |
| add("major", "A11y", f"{label}: form field with no label", | |
| f"name={inp.get('name')} type={inp.get('type')}", path) | |
| if s.autofocus > 1: | |
| add("minor", "A11y", f"{label} has {s.autofocus} autofocus elements", | |
| "Only one element can win; the rest are dead markup.", path) | |
| dupe = {i for i in s.ids if s.ids.count(i) > 1} | |
| if dupe: | |
| add("major", "Markup", f"{label} has duplicate element ids", | |
| ", ".join(sorted(dupe)[:6]), path) | |
| for a in s.links: | |
| href = a.get("href", "") | |
| if a.get("target") == "_blank" and "noopener" not in (a.get("rel") or ""): | |
| add("major", "Security", f"{label}: target=_blank without rel=noopener", | |
| href[:70], path) | |
| if href in ("#", "") and "hx-" not in " ".join(a.keys()): | |
| add("minor", "Markup", f"{label}: link with empty href", "", path) | |
| # ββ 3. Security headers βββββββββββββββββββββββββββββββββββββββββββββ | |
| st, hdr, _, _ = results.get("/saved", (0, {}, "", 0)) | |
| if st == 200: | |
| want = { | |
| "x-content-type-options": ("minor", "Stops MIME sniffing."), | |
| "referrer-policy": ("minor", "Controls what leaks to arxiv.org on outbound clicks."), | |
| "content-security-policy": ("minor", "Defence in depth against injected script."), | |
| } | |
| low = {k.lower() for k in hdr} | |
| for h, (sev, why) in want.items(): | |
| if h not in low: | |
| add(sev, "Security", f"No {h} header", why, "all pages") | |
| # ββ 4. CSS hygiene ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| css = open("app/static/styles.css").read() | |
| stripped = re.sub(r"/\*.*?\*/", "", css, flags=re.S) | |
| defined = set(re.findall(r"\.([a-zA-Z][\w-]*)[\s,{:.\[]", stripped)) | |
| tmpl = "" | |
| import glob | |
| for f in glob.glob("app/templates/**/*.html", recursive=True): | |
| tmpl += open(f).read() | |
| tmpl += open("app/static/app.js").read() | |
| used = set(re.findall(r'class="([^"]*)"', tmpl)) | |
| used_tokens = set() | |
| for u in used: | |
| used_tokens |= set(re.findall(r"[a-zA-Z][\w-]*", u)) | |
| used_tokens |= set(re.findall(r"classList\.(?:add|remove|toggle|contains)\('([\w-]+)'", tmpl)) | |
| used_tokens |= set(re.findall(r'querySelector(?:All)?\([\'"]\.([\w-]+)', tmpl)) | |
| ignore = {"htmx-request", "htmx-indicator", "sr-only", "hidden", "is-out", | |
| "is-leaving", "is-clamped", "just-saved", "watched"} | |
| orphan = sorted(c for c in defined - used_tokens - ignore | |
| if not c.startswith(("cat-", "tone-", "skel", "btn", "stack", | |
| "coll", "ob-", "seed", "card", "feed", | |
| "issue", "sec-", "anchor", "interest", | |
| "meter", "strength", "toast", "empty", | |
| "botnav", "nav", "brand", "icon", "pill", | |
| "row", "wrap", "search", "ai-", "notice", | |
| "spinner", "grow", "muted", "small", | |
| "mono", "center", "scroll", "shell", | |
| "topbar", "skip", "main", "page", "q", | |
| "chip", "rank", "seq", "stat", "plot", | |
| "bar", "mean", "xaxis", "table", "warn", | |
| "eyebrow", "masthead", "dateline", | |
| "thesis", "chart", "entry", "col", | |
| "line", "follow", "back", "undo", "msg", | |
| "lead", "rest", "label", "name", "desc", | |
| "n", "sep", "i"))) | |
| if orphan: | |
| add("minor", "Hygiene", f"{len(orphan)} CSS classes defined but never used", | |
| ", ".join(orphan[:14]) + ("β¦" if len(orphan) > 14 else ""), | |
| "app/static/styles.css") | |
| # ββ 5. Contrast of the real token pairs βββββββββββββββββββββββββββββ | |
| def tokens(block): | |
| m = re.search(block + r"\s*\{(.*?)\n\}", css, re.S) | |
| if not m: | |
| return {} | |
| return dict(re.findall(r"(--[\w-]+):\s*(#[0-9A-Fa-f]{3,8})", m.group(1))) | |
| light = tokens(r"^:root") | |
| dark = tokens(r':root\[data-theme="dark"\]') | |
| for name, t in (("light", light), ("dark", dark)): | |
| if not t: | |
| continue | |
| pairs = [ | |
| ("--text", "--surface", 4.5, "body text on a card"), | |
| ("--text-2", "--surface", 4.5, "secondary text on a card"), | |
| ("--text-3", "--surface", 4.5, "meta text on a card"), | |
| ("--text-3", "--bg", 4.5, "meta text on the page"), | |
| ("--accent", "--surface", 4.5, "accent text / links"), | |
| ("--muted" if "--muted" in t else "--text-3", "--bg", 4.5, "muted on page"), | |
| ] | |
| for fg, bgk, need, what in pairs: | |
| if fg in t and bgk in t: | |
| r = ratio(t[fg], t[bgk]) | |
| if r < 3.0: | |
| add("major", "A11y", f"Contrast fails badly ({name}): {what}", | |
| f"{fg} {t[fg]} on {bgk} {t[bgk]} = {r:.2f}:1, " | |
| f"WCAG AA needs {need}:1", "styles.css") | |
| elif r < need: | |
| add("minor", "A11y", f"Contrast below AA ({name}): {what}", | |
| f"{fg} {t[fg]} on {bgk} {t[bgk]} = {r:.2f}:1 " | |
| f"(AA {need}:1; ok only for text β₯18.66px bold / 24px)", | |
| "styles.css") | |
| # ββ 6. Payload ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| for asset in ("/static/styles.css", "/static/app.js", "/static/htmx.min.js"): | |
| st, hdr, body, dt = fetch(asset, cookie=False) | |
| if st != 200: | |
| add("major", "Availability", f"{asset} returned {st}", "", asset) | |
| continue | |
| kb = len(body.encode()) / 1024 | |
| enc = (hdr.get("Content-Encoding") or "none") | |
| cache = hdr.get("Cache-Control", "") | |
| if kb > 45 and enc == "none": | |
| add("minor", "Performance", f"{asset} served uncompressed ({kb:.0f} KB)", | |
| "No Content-Encoding. gzip typically cuts CSS/JS by 70-80%.", asset) | |
| if not cache: | |
| add("minor", "Performance", f"{asset} has no Cache-Control", | |
| "Every page load re-downloads it; there is no cache busting in " | |
| "the URL either, so a long max-age would need a version query.", | |
| asset) | |
| print(json.dumps(findings, indent=1)) | |
| order = {"critical": 0, "major": 1, "minor": 2} | |
| findings.sort(key=lambda f: order[f["sev"]]) | |
| with open("audit.json", "w") as fh: | |
| json.dump(findings, fh, indent=1) | |
| from collections import Counter | |
| print("\n# summary:", dict(Counter(f["sev"] for f in findings)), file=sys.stderr) | |
| main() | |