"""审计生成的站点:链接、HTML 结构、SEO、可访问性。 用法: python website/audit_site.py [站点目录] 默认审计 website/dist/site。 """ from __future__ import annotations import html import json import re import sys from collections import Counter from pathlib import Path from urllib.parse import unquote, urlparse ROOT = Path(__file__).resolve().parent.parent SITE = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "website" / "dist" / "site" problems: list[str] = [] notes: list[str] = [] def report(kind: str, page: str, message: str) -> None: problems.append(f"[{kind}] {page}: {message}") HTML_PAGES = sorted(SITE.rglob("*.html")) all_files = {p.relative_to(SITE).as_posix() for p in SITE.rglob("*") if p.is_file()} # --------------------------------------------------------------------------- # 1. 链接检查 # --------------------------------------------------------------------------- ATTR_RE = re.compile(r"""\b(href|src)\s*=\s*(["'])(.*?)\2""", re.S | re.I) ID_RE = re.compile(r"""\bid\s*=\s*(["'])(.*?)\1""", re.S | re.I) page_ids: dict[str, set[str]] = {} for page in HTML_PAGES: text = page.read_text(encoding="utf-8") page_ids[page.relative_to(SITE).as_posix()] = {m.group(2) for m in ID_RE.finditer(text)} broken_links = 0 external_links: Counter[str] = Counter() for page in HTML_PAGES: rel = page.relative_to(SITE).as_posix() text = page.read_text(encoding="utf-8") for match in ATTR_RE.finditer(text): attr, raw = match.group(1).lower(), match.group(3).strip() if not raw or raw.startswith(("javascript:", "data:", "mailto:", "tel:", "#")): if raw.startswith("#"): anchor = unquote(raw[1:]) if anchor and anchor not in page_ids[rel]: report("锚点", rel, f"指向本页不存在的 id: {raw}") continue parsed = urlparse(raw) if parsed.scheme in ("http", "https"): external_links[parsed.netloc] += 1 continue if parsed.netloc: report("链接", rel, f"未知协议/域名: {raw}") continue target = unquote(parsed.path) if not target: continue base = page.parent dest = (base / target).resolve() try: dest_rel = dest.relative_to(SITE.resolve()) except ValueError: report("链接", rel, f"指向站点外部: {raw}") continue if target.endswith("/"): dest = dest / "index.html" if not dest.exists(): report("链接", rel, f"目标不存在: {raw}") broken_links += 1 elif parsed.fragment: frag = unquote(parsed.fragment) dest_rel = dest_rel.as_posix() if dest_rel in page_ids and frag not in page_ids[dest_rel]: report("锚点", rel, f"{raw} -> 目标页无 id: {frag}") # --------------------------------------------------------------------------- # 2. HTML 结构检查 # --------------------------------------------------------------------------- VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"} BLOCK = {"html", "head", "body", "div", "main", "header", "footer", "nav", "section", "article", "aside", "ul", "ol", "li", "table", "tbody", "thead", "tr", "td", "th", "form", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote", "details", "summary", "figure", "picture", "video", "audio", "dl", "dt", "dd", "hr", "fieldset", "option", "select", "textarea", "button", "nav", "template", "svg", "canvas", "iframe", "span", "label", "code", "a", "strong", "em", "b", "i", "small", "sub", "sup", "br", "img", "table", "caption", "colgroup", "style", "script", "noscript"} for page in HTML_PAGES: rel = page.relative_to(SITE).as_posix() text = page.read_text(encoding="utf-8") # 重定向页(不带 .html 的入口)只有一个跳转,不需要完整的页面结构 if 'http-equiv="refresh"' in text: continue # doctype if not text.lstrip().lower().startswith("") # lang 属性 m = re.search(r"]*)>", text, re.I) if not m: report("结构", rel, "缺少 标签") elif not re.search(r"\blang\s*=", m.group(1), re.I): report("SEO", rel, " 缺少 lang 属性") # title if not re.search(r"