站点:纯静态 HTML + CSS + 原生 JS,零框架零构建
页面:首页 / 合作品牌 / 荣誉资质 / 客户案例 / 关于我们
内容:
- site/ 站点本体(含已压缩 webp 图片,约 4.8 MB)
- deploy/ Docker Compose / Caddy / Apache 部署配置
- _tools/ 开发自检脚本与部署脚本
- Dockerfile 自建 nginx 镜像方案
- README.md 项目文档
设计:品牌黄 #ffc107 + 白,深蓝仅用于页脚;菜单长期置顶;
交互动效只加在关键词与区块级元素上。
素材说明:原始素材(背景图/老网站/方案/荣誉资质/合作品牌,约 290 MB)
未纳入版本控制,站点使用的是 site/assets 内已处理压缩的图片。
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""HTML 结构校验:标签嵌套是否合法、必需元素是否齐全、无障碍属性是否到位。"""
|
||
import os
|
||
import re
|
||
import sys
|
||
from html.parser import HTMLParser
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
SITE = os.path.join(ROOT, "site")
|
||
PAGES = ["index.html", "brands.html", "honors.html", "cases.html", "about.html"]
|
||
|
||
VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
|
||
"meta", "param", "source", "track", "wbr"}
|
||
OPTIONAL_END = {"li", "p", "td", "th", "tr", "option", "thead", "tbody"}
|
||
|
||
|
||
class Checker(HTMLParser):
|
||
def __init__(self):
|
||
super().__init__(convert_charrefs=True)
|
||
self.stack = []
|
||
self.errors = []
|
||
self.tags = {}
|
||
self.attrs = []
|
||
self.texts = []
|
||
|
||
def handle_starttag(self, tag, attrs):
|
||
self.tags[tag] = self.tags.get(tag, 0) + 1
|
||
self.attrs.append((tag, dict(attrs)))
|
||
if tag not in VOID:
|
||
self.stack.append((tag, self.getpos()))
|
||
|
||
def handle_startendtag(self, tag, attrs):
|
||
self.tags[tag] = self.tags.get(tag, 0) + 1
|
||
self.attrs.append((tag, dict(attrs)))
|
||
|
||
def handle_endtag(self, tag):
|
||
if tag in VOID:
|
||
self.errors.append("闭合了自闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||
return
|
||
if not self.stack:
|
||
self.errors.append("多余的闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||
return
|
||
if self.stack[-1][0] == tag:
|
||
self.stack.pop()
|
||
return
|
||
# 容忍可省略的闭合标签
|
||
names = [t for t, _ in self.stack]
|
||
if tag in names:
|
||
idx = len(names) - 1 - names[::-1].index(tag)
|
||
skipped = names[idx + 1:]
|
||
if all(s in OPTIONAL_END for s in skipped):
|
||
del self.stack[idx:]
|
||
return
|
||
self.errors.append(
|
||
"</%s> 行 %d 与最近未闭合标签 <%s> 行 %d 交叉嵌套(中间有 %s)"
|
||
% (tag, self.getpos()[0], self.stack[-1][0], self.stack[-1][1][0],
|
||
", ".join(skipped)))
|
||
del self.stack[idx:]
|
||
return
|
||
self.errors.append("未匹配的闭合标签 </%s> 行 %d" % (tag, self.getpos()[0]))
|
||
|
||
def handle_data(self, data):
|
||
t = data.strip()
|
||
if t:
|
||
self.texts.append(t)
|
||
|
||
|
||
def main():
|
||
problems = 0
|
||
for page in PAGES:
|
||
path = os.path.join(SITE, page)
|
||
html = open(path, encoding="utf-8").read()
|
||
c = Checker()
|
||
c.feed(html)
|
||
c.close()
|
||
errs = list(c.errors)
|
||
for tag, pos in c.stack:
|
||
errs.append("标签 <%s> 行 %d 未闭合" % (tag, pos[0]))
|
||
|
||
# 必需结构
|
||
need = {
|
||
"html": "html 标签",
|
||
"head": "head 标签",
|
||
"body": "body 标签",
|
||
"main": "main 标签",
|
||
"h1": "h1 标题",
|
||
"title": "title",
|
||
}
|
||
for tag, label in need.items():
|
||
if c.tags.get(tag, 0) == 0:
|
||
errs.append("缺少 %s" % label)
|
||
if c.tags.get("h1", 0) > 1:
|
||
errs.append("存在 %d 个 h1(应为 1 个)" % c.tags["h1"])
|
||
|
||
# 图片必须有 alt
|
||
for tag, at in c.attrs:
|
||
if tag == "img" and "alt" not in at:
|
||
errs.append("<img src=%s> 缺少 alt" % at.get("src", "?"))
|
||
if tag == "html" and at.get("lang") != "zh-CN":
|
||
errs.append("<html> 缺少 lang=\"zh-CN\"")
|
||
if tag == "a":
|
||
if at.get("target") == "_blank" and "noopener" not in (at.get("rel") or ""):
|
||
errs.append("外链 %s 缺少 rel=noopener" % at.get("href", "?"))
|
||
|
||
# 图片尺寸提示(避免布局抖动)
|
||
#
|
||
# 例外:品牌 logo 由 CSS 用「固定高度 + 宽度自动」控制大小
|
||
# (见 main.css 的 .brandstrip .marquee__item img)。
|
||
# 这类图片不能写 width/height 属性 —— logo 源图自带大量透明留白,
|
||
# 属性声明的宽高比(360/240 = 1.5)与实际内容(锐捷 320/54 = 5.9)不符,
|
||
# 浏览器会按错误比例算宽度并把 logo 压扁。
|
||
# 它们位于固定高度的卡片内居中显示,不会造成布局抖动。
|
||
def css_controls_size(at):
|
||
if "img/brands/" in (at.get("src") or ""):
|
||
return True # 品牌 logo:由 CSS 统一控制尺寸
|
||
if "brand__mark" in (at.get("class") or ""):
|
||
return True # 站点 Logo:CSS 固定 40px
|
||
return False
|
||
|
||
imgs = [at for tag, at in c.attrs if tag == "img"]
|
||
no_dim = [i for i in imgs
|
||
if not (i.get("width") and i.get("height"))
|
||
and not css_controls_size(i)]
|
||
if no_dim:
|
||
errs.append("%d 张图片缺少 width/height(会抖动布局):%s"
|
||
% (len(no_dim), ", ".join(i.get("src", "?") for i in no_dim[:3])))
|
||
|
||
# 按钮必须有可读文字或 aria-label
|
||
for tag, at in c.attrs:
|
||
if tag == "button" and not at.get("aria-label") and at.get("data-lightbox") is None:
|
||
pass # 由子元素文字提供
|
||
|
||
if errs:
|
||
problems += len(errs)
|
||
print("[%s]" % page)
|
||
for e in errs:
|
||
print(" x " + e)
|
||
else:
|
||
print("[%s] 结构 OK 标签 %d 个 / 图片 %d 张 / 文本块 %d 段"
|
||
% (page, sum(c.tags.values()), len(imgs), len(c.texts)))
|
||
|
||
print("\nHTML 结构校验:" + ("全部通过 OK" if problems == 0 else "%d 个问题" % problems))
|
||
return 0 if problems == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|