"""审计生成的站点:链接、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".+?", text, re.S | re.I): report("SEO", rel, "缺少非空 ") # meta description md = re.search(r'<meta\s+name=["\']description["\']\s+content=["\'](.*?)["\']', text, re.S | re.I) if not md: report("SEO", rel, "缺少 meta description") elif not md.group(1).strip(): report("SEO", rel, "meta description 为空") # viewport if not re.search(r'<meta\s+name=["\']viewport["\']', text, re.I): report("SEO", rel, "缺少 viewport meta") # 标题层级:h1 应唯一 h1s = re.findall(r"<h1\b", text, re.I) if len(h1s) != 1: report("结构", rel, f"h1 数量为 {len(h1s)}(应为 1)") # 标题层级不跳级 levels = [int(m) for m in re.findall(r"<h([1-6])\b", text, re.I)] for prev, cur in zip(levels, levels[1:]): if cur > prev + 1: report("结构", rel, f"标题层级跳跃 h{prev} -> h{cur}") # 标签配对(简单栈式检查,忽略 void 与 script/style 内容) stripped = re.sub(r"<(script|style)\b.*?</\1>", "", text, flags=re.S | re.I) stack: list[str] = [] for m in re.finditer(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(/?)>", stripped): closing, tag, selfclose = m.group(1), m.group(2).lower(), m.group(3) if tag in VOID or selfclose: continue if closing: if not stack: report("结构", rel, f"多余的 </{tag}>") elif stack[-1] != tag: if tag in stack: while stack and stack[-1] != tag: report("结构", rel, f"标签未闭合: <{stack.pop()}>") stack.pop() else: report("结构", rel, f"标签错配: </{tag}> 对应 <{stack[-1]}>") stack.pop() else: stack.pop() else: stack.append(tag) for leftover in reversed(stack): report("结构", rel, f"标签未闭合: <{leftover}>") # 重复 id ids = [m.group(2) for m in ID_RE.finditer(text)] dupes = [i for i, c in Counter(ids).items() if c > 1] if dupes: report("结构", rel, f"重复 id: {dupes[:5]}") # 图片 alt for m in re.finditer(r"<img\b[^>]*>", text, re.I | re.S): if not re.search(r"\balt\s*=", m.group(0), re.I): report("可访问性", rel, f"img 缺少 alt: {m.group(0)[:70]}") # 链接文本 for m in re.finditer(r"<a\b([^>]*)>(.*?)</a>", text, re.S | re.I): attrs, inner = m.group(1), m.group(2) if 'aria-label="' in attrs or "aria-label='" in attrs: continue text_only = re.sub(r"<[^>]+>", "", inner).strip() if not text_only and not re.search(r"\bdownload\b", attrs, re.I): report("可访问性", rel, f"空链接文本: {attrs[:70]}") # 按钮缺 type for m in re.finditer(r"<button\b[^>]*>", text, re.I): if not re.search(r"\btype\s*=", m.group(0), re.I): report("结构", rel, "button 缺少 type 属性") # 硬编码语言 if 'lang="en"' in text.lower() and "en/" in rel: pass # --------------------------------------------------------------------------- # 3. 跨页面语言检查 # --------------------------------------------------------------------------- for page in HTML_PAGES: rel = page.relative_to(SITE).as_posix() text = page.read_text(encoding="utf-8") m = re.search(r"<html\b([^>]*)>", text, re.I) lang = re.search(r'lang=["\']([^"\']+)["\']', m.group(1) if m else "") expected = "en" if rel.startswith("en/") else "zh-CN" if lang and lang.group(1) != expected: report("i18n", rel, f"lang={lang.group(1)} 期望 {expected}") # 中英文页面数量配平 zh_pages = {p.relative_to(SITE).as_posix() for p in HTML_PAGES if not p.relative_to(SITE).as_posix().startswith("en/")} en_pages = {p.relative_to(SITE).as_posix()[3:] for p in HTML_PAGES if p.relative_to(SITE).as_posix().startswith("en/")} if zh_pages != en_pages: only_zh = sorted(zh_pages - en_pages) only_en = sorted(en_pages - zh_pages) if only_zh: report("i18n", "-", f"只有中文的页面: {only_zh}") if only_en: report("i18n", "-", f"只有英文的页面: {only_en}") # --------------------------------------------------------------------------- # 4. 静态资源 # --------------------------------------------------------------------------- for page in HTML_PAGES: rel = page.relative_to(SITE).as_posix() text = page.read_text(encoding="utf-8") for m in re.finditer(r'<script\b[^>]*\bsrc=["\']([^"\']+)["\']', text, re.I): src = urlparse(m.group(1)).path p = (page.parent / unquote(src)).resolve() if not p.exists(): report("资源", rel, f"脚本不存在: {m.group(1)}") # 孤立的静态资源 referenced: set[str] = set() for page in HTML_PAGES: text = page.read_text(encoding="utf-8") for m in ATTR_RE.finditer(text): raw = m.group(3).strip() if raw.startswith(("http", "mailto:", "javascript:", "data:", "#")): continue target = unquote(urlparse(raw).path) if target: referenced.add(((page.parent / target).resolve()).as_posix()) for f in SITE.rglob("*"): if f.is_file() and f.suffix.lower() in (".css", ".js", ".jpg", ".png", ".svg", ".ico"): if f.resolve().as_posix() not in referenced: notes.append(f"未被引用的静态文件: {f.relative_to(SITE).as_posix()}") # --------------------------------------------------------------------------- # 5. api.json # --------------------------------------------------------------------------- api_json = SITE / "api.json" if api_json.exists(): try: data = json.loads(api_json.read_text(encoding="utf-8")) notes.append(f"api.json 解析正常,顶层键: {list(data)[:8] if isinstance(data, dict) else type(data)}") except Exception as exc: report("api.json", "-", f"解析失败: {exc}") # --------------------------------------------------------------------------- # 6. 控制台输出 # --------------------------------------------------------------------------- print("=" * 60) print(f"审计目录: {SITE}") print(f"页面数: {len(HTML_PAGES)} 文件数: {len(all_files)}") print("=" * 60) if problems: print(f"\n发现 {len(problems)} 个问题:\n") for p in problems: print(" " + p) else: print("\n未发现问题。") if notes: print(f"\n提示 ({len(notes)}):") for n in notes: print(" - " + n) if external_links: print("\n外部域名引用:") for domain, count in external_links.most_common(): print(f" {domain}: {count}") print() sys.exit(1 if problems else 0)