This commit is contained in:
2026-11-02 17:00:28 +08:00
parent 5d0f629439
commit 9ed39b51f7
43 changed files with 7163 additions and 151 deletions

279
website/audit_site.py Normal file
View File

@@ -0,0 +1,279 @@
"""审计生成的站点:链接、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")
# doctype
if not text.lstrip().lower().startswith("<!doctype html"):
report("结构", rel, "缺少 <!DOCTYPE html>")
# lang 属性
m = re.search(r"<html\b([^>]*)>", text, re.I)
if not m:
report("结构", rel, "缺少 <html> 标签")
elif not re.search(r"\blang\s*=", m.group(1), re.I):
report("SEO", rel, "<html> 缺少 lang 属性")
# title
if not re.search(r"<title>.+?</title>", text, re.S | re.I):
report("SEO", rel, "缺少非空 <title>")
# 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)