CSS 证据分析器 analyze-css-evidence
scripts/analyze-css-evidence.py 是 ark-ui 技能质量工具链中的官方样式证据提取器:它从一条 URL 或一个本地 CSS 文件中,提取可复现的颜色、字体、@font-face、动效与高级特性证据,并附带 SHA-256 指纹与抓取响应头,输出为结构化 JSON,供 references/source-ledger.md 证据台账引用。
目的与范围
本页覆盖该分析器的完整机制:命令行接口、read_source 数据获取层(HTTP + gzip / 本地文件双通道)、五组正则构成的核心提取层、归一化与计数策略、@font-face 与 data-URI 折叠逻辑、features 特性面板,以及输出 JSON 的数据模型、失败模式与扩展点。
以下内容属于兄弟页面,本页只做交叉引用、不展开:
- 启发式审计(可访问性 / 响应式 / 仿制陈词滥调检查):由
scripts/audit-ark-ui.mjs承担,与本分析器互为互补——本工具提取"官方页面真实用了什么",审计器检查"我们自己的产物缺什么"。 - CDP 视口渲染与截图校验:由
scripts/capture-showcases.mjs承担,处理的是渲染期证据(横向溢出等),而本工具只处理静态 CSS 文本证据。 - 证据台账的维护规范与分级(Direct / Supported / Inference):台账文件
references/source-ledger.md自身即是产物页面,本页只说明分析器如何向它供料。
概述
ark-ui 技能要求"仿制一个官方站点视觉"必须建立在可复现的直接观察之上,而不是凭印象调色。当任务需要研究 ledger 尚未覆盖的新官方页面,或需要复核已有证据是否仍然新鲜时,SKILL.md 规定的工作流是:
python3 "$CODEZ_HOME/skills/ark-ui/scripts/analyze-css-evidence.py" <css-url-or-file>Source: SKILL.md
随后把页面 URL、资产 URL、抓取日期、观察到的框架、颜色、字体与可复用模式记录进 references/source-ledger.md,并把直接观察与推断分开陈述。
设计意图有三层:
- 可复现性(Provenance):输出中的
sha256与retrieval_headers让任何一次结论都能回溯到具体的字节流。台账中的 "Production asset manifest" 一节正是逐条记录 SHA-256 与解码后字节数,使"某官方样式表是否变化"成为一个可验证的布尔问题。 - 零依赖:整个脚本只使用 Python 标准库(
argparse、gzip、hashlib、json、re、urllib.request、collections.Counter、pathlib),不引入任何第三方包,保证在任何有python3的环境可直接运行。 - 证据而非判断:脚本不做美学评价,只输出事实计数(例如
mask: 14、mix_blend_mode: 8),把"这暗示了什么"留给台账的 Direct/Inference 分级去处理。
架构
分层说明:
- CLI 层(
main()的argparse部分)只负责参数解析,保持入口极薄。 - 数据获取层(
read_source)是唯一有 I/O 副作用的部分,用 URL scheme 判定走 HTTP 还是本地文件;HTTP 分支自行声明 User-Agent 与 gzip 能力,并在本地按魔数(\x1f\x8b)决定是否解压,而不是信任Content-Encoding头——这让缓存服务器行为不一致时依然正确。 - 提取层是纯函数式的文本扫描:五组模块级预编译正则 + 若干
re.findall特性计数,全部只读text一个输入。 - 指纹层在
result里集中承载溯源信息(原始source、响应头、解码字节数、SHA-256)。 - 输出层单次
json.dumps(ensure_ascii=False, indent=2)打印到 stdout,无文件写出——管道友好,由调用方决定去向(终端阅读或写入台账)。
整个模块只有 4 个函数与 5 个模块级正则常量,无类、无状态、无全局可变数据;这是刻意的最小化设计,使得每一次运行都是输入的确定函数。
核心流程:从一条 URL 到一份 JSON 证据
下面按真实控制流逐步走读。main() 的执行顺序是:解析参数 → read_source → 解码 → 三类并行扫描(颜色/字体、@font-face、features)→ 组装 result → 打印。
第 1 步:命令行接口
1def main() -> int:
2 parser = argparse.ArgumentParser(description=__doc__)
3 parser.add_argument("source", help="CSS URL or local file")
4 parser.add_argument("--limit", type=int, default=30, help="Maximum entries per list")
5 args = parser.parse_args()Source: analyze-css-evidence.py
description=__doc__ 让脚本顶部的 docstring("Extract reproducible color, typography, geometry, and motion evidence from CSS.")直接成为 --help 文案,避免了帮助文本与实现漂移。--limit 默认 30,控制 colors / font_families / font_faces / keyframes 四个列表的截断长度,防止对超大生产样式表(台账中最大的 CSS 解码后约 581 KB)输出失控。
第 2 步:获取源字节(read_source)
1def read_source(source: str) -> tuple[bytes, dict[str, str]]:
2 parsed = urlparse(source)
3 if parsed.scheme in {"http", "https"}:
4 request = urllib.request.Request(
5 source,
6 headers={"User-Agent": "ark-ui-evidence/1.0", "Accept-Encoding": "gzip"},
7 )
8 with urllib.request.urlopen(request, timeout=30) as response:
9 body = response.read()
10 headers = {key.lower(): value for key, value in response.headers.items()}
11 if body.startswith(b"\x1f\x8b"):
12 body = gzip.decompress(body)
13 return body, headers
14
15 path = Path(source).expanduser().resolve()
16 return path.read_bytes(), {"path": str(path)}Source: analyze-css-evidence.py
要点与设计意图:
- URL scheme 判定而非"是否存在":
urlparse解析后只看 scheme,因此https://.../index.css走网络,./assets/react/ark-ui.css或~/Downloads/foo.css走本地——同一个入口同时服务"研究新官方页"与"复核仓库自带 CSS"两种场景。 - 自定义 User-Agent
ark-ui-evidence/1.0:标识这是一个证据采集脚本而非浏览器,便于服务端日志审计,也让 CDN 不会返回针对浏览器 UA 的变体。 - gzip 魔数检测:
Accept-Encoding: gzip之后不解析Content-Encoding头,而是直接看字节是否以\x1f\x8b开头再解压。这是对"头可能缺失/不一致"这一现实条件的防御式处理,保证decoded_bytes与sha256始终对应解压后的明文 CSS。 - headers 全部小写化:HTTP 头名大小写不敏感,统一小写后下游 JSON 输出稳定可比较。
- 超时 30 秒:单文件、单请求、无重试。这是刻意的"一次性取证"语义——失败即让上层(人或 agent)看到异常,而不是静默重试出陈旧证据。
第 3 步:解码与颜色/字体统计
1 body, headers = read_source(args.source)
2 text = body.decode("utf-8", errors="replace")
3 normalized_colors = Counter(match.group(0).lower().replace(" ", "") for match in COLOR_RE.finditer(text))
4 fonts = Counter(" ".join(match.group(1).strip().split()) for match in FONT_RE.finditer(text))Source: analyze-css-evidence.py
两处归一化都在计数之前完成,这是频次排名可信的前提:
- 颜色:
match.group(0).lower().replace(" ", "")把RGB(24, 209, 255)、rgb(24,209,255)、rgb( 24 , 209 ,255 )折叠成同一个键,避免同一颜色因书写风格被拆成多行。 - 字体:
" ".join(match.group(1).strip().split())把任意空白(换行、多空格、tab)折叠成单空格,使font-family: Bender,\n Oswald与font-family: Bender, Oswald等价。
errors="replace" 保证非 UTF-8 字节不会让整个分析崩溃——取证脚本宁可得到带 U+FFFD 的文本,也不要得不到指纹。
第 4 步:@font-face 结构化与 data:URI 折叠
1 font_faces = []
2 for block in FONT_FACE_RE.findall(text):
3 family = FONT_RE.search(block)
4 sources = [summarize_url(url) for url in URL_RE.findall(block)]
5 font_faces.append({"family": family.group(1).strip() if family else None, "sources": sources[:8]})1def summarize_url(value: str) -> str:
2 value = value.strip(" \"'")
3 if value.startswith("data:"):
4 media_type = value[5:].split(";", 1)[0] or "application/octet-stream"
5 return f"data:{media_type};base64,(embedded {len(value):,} chars)"
6 return valueSource: analyze-css-evidence.py
这一段是"内嵌字体证据"的关键:生产 CSS 常把字体以 data URI 内嵌,若原样输出会把 JSON 撑到数 MB 且泄露不可读的 base64。summarize_url 把它折叠成 data:font/woff2;base64,(embedded 148,920 chars) 这样的摘要——保留媒体类型与体量信息,丢弃载荷。同理 family 缺失时显式写 None 而不是猜测,sources 截断到前 8 条。
第 5 步:features 特性面板
1 features = {
2 "clip_path": len(re.findall(r"(?:-webkit-)?clip-path\s*:", text, re.I)),
3 "mask": len(re.findall(r"(?:-webkit-)?mask(?:-image)?\s*:", text, re.I)),
4 "mix_blend_mode": len(re.findall(r"mix-blend-mode\s*:", text, re.I)),
5 "backdrop_filter": len(re.findall(r"(?:-webkit-)?backdrop-filter\s*:", text, re.I)),
6 "orientation_queries": len(re.findall(r"orientation\s*:", text, re.I)),
7 "reduced_motion_queries": len(re.findall(r"prefers-reduced-motion", text, re.I)),
8 "keyframes": sorted(set(KEYFRAME_RE.findall(text)))[: args.limit],
9 }Source: analyze-css-evidence.py
六个计数键回答的是"这个官方视觉的技术签名是什么":
| 键 | 回答的问题 | 台账中的对应证据 |
|---|---|---|
clip_path | 是否用裁剪路径做几何切角/异形 | Endfield CSS 的 clip paths 与黄色加载擦除 |
mask | 是否用遮罩做文字/图像融合 | Arknights CSS 14 条 mask;Ex Astris 44 条 mask |
mix_blend_mode | 是否用混合模式做叠色 | Arknights CSS 8 条 blend-mode |
backdrop_filter | 是否有毛玻璃/背景滤镜 | Hypergryph 半透明炭黑头部 |
orientation_queries | 是否有横竖屏分版 | Arknights 的 orientation-specific layout |
reduced_motion_queries | 是否尊重 prefers-reduced-motion | SKILL.md Validate 第 3 条要求检查的行为 |
keyframes 与其余键不同:它是 sorted(set(...)) 去重排序后的名称列表(如 Ex Astris 的 orbital/point/glint 关键帧族),因为动效语义藏在命名里,而计数会丢失这层信息。所有正则都用 (?:-webkit-)? 兼容带前缀写法,re.I 兼容大小写。
第 6 步:组装并打印
1 result = {
2 "source": args.source,
3 "retrieval_headers": headers,
4 "decoded_bytes": len(body),
5 "sha256": hashlib.sha256(body).hexdigest(),
6 "colors": top(normalized_colors, args.limit),
7 "font_families": top(fonts, args.limit),
8 "font_faces": font_faces[: args.limit],
9 "features": features,
10 }
11 print(json.dumps(result, ensure_ascii=False, indent=2))
12 return 0def top(counter: Counter[str], limit: int = 30) -> list[dict[str, object]]:
return [{"value": value, "count": count} for value, count in counter.most_common(limit)]Source: analyze-css-evidence.py
top() 把 Counter 转成 [{"value": ..., "count": ...}] 的列表形态——刻意不用字典(会丢排序),也不直接序列化 Counter。ensure_ascii=False 保证非 ASCII 字体名(如 CJK 字体)原样输出而不是 \uXXXX 转义。main() 恒返回 0,退出码不承载业务判定,判定交给读 JSON 的人/agent。
数据模型
字段语义与来源:
| 字段 | 类型 | 来源 | 用途 |
|---|---|---|---|
source | string | CLI 参数 | 回溯输入,URL 或本地路径 |
retrieval_headers | map(str→str) | HTTP 响应头(小写化)或 {"path": ...} | 记录 Last-Modified / ETag / Content-Type 等抓取上下文 |
decoded_bytes | int | len(body) | gzip 解压后的字节数,用于与台账字节数比对 |
sha256 | string | hashlib.sha256(body).hexdigest() | 唯一指纹;台账靠它判定"资产是否变化" |
colors | list | top(COLORER) | 归一化颜色的频次排名 → 提取主色/信号色 |
font_families | list | top(FONT_COUNTER) | 归一化 font-family 栈的频次排名 |
font_faces | list | FONT_FACE_RE 解析 | family + 最多 8 个折叠后的来源 |
features | dict | 6 组计数 + keyframes 列表 | 技术签名,区分"用了什么手段"而非"什么颜色" |
使用示例
示例 1:分析仓库自带的 React 主题样式表(本地文件入口)
python3 "$CODEX_HOME/skills/ark-ui/scripts/analyze-css-evidence.py" \
assets/react/ark-ui.css本地文件分支会返回 {"path": "<绝对路径>"} 作为 retrieval_headers,适合在改动主题 CSS 前后各跑一次,用 sha256 快速确认"证据基线是否被自己改动"。
示例 2:研究一个新官方页面的生产样式(URL 入口)
python3 "$CODEX_HOME/skills/ark-ui/scripts/analyze-css-evidence.py" \
"https://web.hycdn.cn/arknights/official/_next/static/css/3759d2520092f84f.css"这正是 SKILL.md「Research new official pages」一节规定的用法:抓取后把页面 URL、资产 URL、抓取日期、观察到的框架、颜色、字体与可复用模式写入 references/source-ledger.md,并将直接观察与推断分开。
Sources:
示例 3:缩小输出列表
python3 scripts/analyze-css-evidence.py assets/showcases/showcase.css --limit 10--limit 10 会同时截断 colors、font_families、font_faces 与 features.keyframes 四个列表,用于快速浏览长尾不重要的场景。
示例 4:五组提取正则(提取层的全部输入约定)
1COLOR_RE = re.compile(r"#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla)\([^)]*\)")
2FONT_RE = re.compile(r"font-family\s*:\s*([^;}]+)", re.I)
3FONT_FACE_RE = re.compile(r"@font-face\s*{([^}]+)}", re.I | re.S)
4KEYFRAME_RE = re.compile(r"@(?:-webkit-)?keyframes\s+([\w-]+)", re.I)
5URL_RE = re.compile(r"url\(([^)]+)\)", re.I)Source: analyze-css-evidence.py
这五条正则共同构成"证据词汇表":颜色字面量、font-family 声明、@font-face 块体(re.S 允许块内换行)、keyframes 名称(兼容 -webkit- 前缀)、以及 url(...) 引用。它们在模块顶层一次性编译,供 main() 与 summarize 流程复用。
示例 5:台账中的真实证据形态(分析器输出的下游去向)
| `55b9681174b545b4b5fbabcc0127afd76a7fe753c2ce302a8ea56f7c380` | 107,343 | 2026-02-09 | https://web.hycdn.cn/arknights/official/_next/static/css/3759d2520092f84b.css |
Source: source-ledger.md
台账把分析器输出的 sha256 + decoded_bytes 与公开资产 URL 逐行对应。当复核发现某官方样式表的 SHA-256 仍是 55b96... 时,即可判定既有色彩/字体证据仍然新鲜——这正是台账「2026-07-20 live verification」小节记录 Arknights、Ex Astris、POPUCOM、Hypergryph 四条复核结论的方式(其中同时引用了分析器的 mask: 14、mix_blend_mode: 8 等 features 计数与 keyframe 家族数量)。
配置项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
source(位置参数,必填) | string | — | CSS 的 URL(http/https)或本地文件路径 |
--limit | int | 30 | colors / font_families / font_faces / features.keyframes 各列表的最大条目数 |
硬编码的行为常量(非 CLI 可配置):
| 常量 | 值 | 位置 |
|---|---|---|
| 请求 User-Agent | ark-ui-evidence/1.0 | read_source 内 |
请求 Accept-Encoding | gzip | read_source 内 |
| HTTP 超时 | 30 秒 | urllib.request.urlopen(request, timeout=30) |
top() 默认 limit | 30 | 函数签名默认值(实际被 args.limit 覆盖) |
font_faces[].sources 截断 | 8 条 | sources[:8] |
API 参考
本脚本无类与导出接口,仅有 4 个模块级函数。可作为库导入复用(python3 -c "import ..." 场景有限,主要仍以 CLI 使用)。
read_source(source: str) -> tuple[bytes, dict[str, str]]
把 URL 或本地路径读取为原始字节,并返回抓取上下文。
参数:
source(str):以http/https开头时走网络请求;否则按本地路径处理(支持~展开与相对路径解析)。
返回:
tuple[bytes, dict[str, str]]:(解压后的字节流, 小写化的响应头字典)。本地文件时第二项为{"path": "<绝对路径>"}。
抛出:
urllib.error.URLError/socket.timeout:网络不可达或超过 30 秒。FileNotFoundError/PermissionError:本地路径不存在或不可读。gzip.BadGzipFile:字节以 gzip 魔数开头但内容损坏。
top(counter: Counter[str], limit: int = 30) -> list[dict[str, object]]
参数:
counter(Counter[str]):已归一化的计数器。limit(int, 可选):返回的最常见条目数,默认 30。
返回: [{"value": str, "count": int}, ...],按 most_common 的频次降序。
summarize_url(value: str) -> str
参数:
value(str):url(...)内捕获的原始字符串,可能带引号或空白。
返回: 去除包裹引号/空白后的值;若为 data: URI,折叠为 data:<media-type>;base64,(embedded N chars) 摘要,媒体类型缺省为 application/octet-stream。
main() -> int
参数: 无(从 sys.argv 解析)。
返回: 恒为 0;把完整 JSON 证据打印到 stdout。
失败模式、边界情况与并发
| 场景 | 行为 | 设计意图 |
|---|---|---|
| URL 不可达 / 超时 | 抛 URLError / timeout,非零退出(未捕获异常 → 解释器退出码非 0) | 取证失败必须显式失败;静默重试会掩盖"证据已过期" |
CDN 忽略 Accept-Encoding 但返回了 gzip | 仍按魔数解压 | 不信任头、只信任字节,保证指纹基于解压后内容 |
| CSS 含 BOM 或非 UTF-8 字节 | errors="replace" 替换为 U+FFFD,继续分析 | 取证优先于纯净;指纹基于原始 bytes,不受解码影响 |
| 同一颜色多种写法 | 计数前统一小写并去空格 | 频次排名才可比 |
| 字体栈含换行/多空格 | str.split() + " ".join 折叠 | 同上 |
| 内嵌字体 data URI | 折叠为摘要,不输出 base64 | 防止 JSON 爆炸与噪声 |
@font-face 块缺失 family | family 置 None,不猜测 | 证据与推断严格分离 |
| 迷你化 CSS(无换行) | FONT_FACE_RE 带 re.S,[^;}]+ / [^)]* 不依赖换行 | 生产 CSS 都是压缩形态,正则必须换行无关 |
| 语法非法的 CSS | 正则只提取匹配到的片段,不解析 CSS | 无需完整解析器,保持零依赖 |
| 并发运行多个实例 | 进程无共享状态、只写 stdout | 可并行抓多个资产,天然安全 |
边界条件上唯一需要留意的语义点:sha256 与 decoded_bytes 基于解压后字节,而 retrieval_headers 中的 Content-Length 可能是压缩长度——两者不可直接相减。
性能与运维要点
- 复杂度:对
text做常数次线性扫描(5 组finditer/findall+ 6 组特性findall),整体 O(n)。台账中最大的 CSS 解码后约 581 KB,属于毫秒级处理量。 - 网络面:单请求、30 秒超时、无重试、无 cookie、无重定向限制逻辑(由
urllib默认行为处理)。生产抓取建议在失败时人工重试并记录抓取日期。 - 运维惯例:SKILL.md 要求"研究新官方页面"时先跑本分析器,再更新台账;台账自身标注"研究 pass 日期 + 时区",并在做时效敏感结论前复核活源。SHA-256 比对是最便宜的"资产是否变化"探针(先比指纹,指纹变了才需要重新解读 colors/features)。
- 缓存:无缓存设计。每次运行都重新抓取,保证指纹与结论同源同刻。
扩展点
- 新增一个证据维度 = 新增一条模块级正则 +
features里一个键。例如想统计grid-template-areas或@container,在features字典里加一行re.findall(r"@container", text, re.I)即可,无需改动架构。 - 换 HTTP 栈:
read_source是唯一的 I/O 函数,签名返回(bytes, headers);若要支持鉴权头、代理或本地缓存,只需替换该函数,提取层完全不动。 - 输出通道:目前仅
print到 stdout;如需直接落盘 JSON 或生成台账行,可在main()尾部追加,不必改动提取逻辑。 - 上游约定:SKILL.md 把本脚本列为 bundled code 之一,描述为"extract color, font, motion, and geometry evidence from public CSS"——扩展时应维持这一"只提取、不评价"的边界。
相关链接
- SKILL.md — Research new official pages 与 Bundled code — 本分析器在技能工作流中的位置
- scripts/analyze-css-evidence.py — 全部实现(95 行)
- references/source-ledger.md — 分析器输出的下游证据台账
- 兄弟工具:
scripts/audit-ark-ui.mjs(启发式审计)、scripts/capture-showcases.mjs/scripts/capture-promos.mjs(CDP 渲染校验)——见 6-quality-toolchain 目录下对应页面