Repository Wiki
Brandon030722/ark-ui-skill

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 规定的工作流是:

bash
python3 "$CODEZ_HOME/skills/ark-ui/scripts/analyze-css-evidence.py" <css-url-or-file>

Source: SKILL.md

随后把页面 URL、资产 URL、抓取日期、观察到的框架、颜色、字体与可复用模式记录进 references/source-ledger.md,并把直接观察与推断分开陈述。

设计意图有三层:

  1. 可复现性(Provenance):输出中的 sha256 与 retrieval_headers 让任何一次结论都能回溯到具体的字节流。台账中的 "Production asset manifest" 一节正是逐条记录 SHA-256 与解码后字节数,使"某官方样式表是否变化"成为一个可验证的布尔问题。
  2. 零依赖:整个脚本只使用 Python 标准库(argparse、gzip、hashlib、json、re、urllib.request、collections.Counter、pathlib),不引入任何第三方包,保证在任何有 python3 的环境可直接运行。
  3. 证据而非判断:脚本不做美学评价,只输出事实计数(例如 mask: 14、mix_blend_mode: 8),把"这暗示了什么"留给台账的 Direct/Inference 分级去处理。

架构

Loading diagram...

分层说明:

  • 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 步:命令行接口

python
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)

python
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 步:解码与颜色/字体统计

python
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 折叠

python
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]})
python
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 value

Source: 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 特性面板

python
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-motionSKILL.md Validate 第 3 条要求检查的行为

keyframes 与其余键不同:它是 sorted(set(...)) 去重排序后的名称列表(如 Ex Astris 的 orbital/point/glint 关键帧族),因为动效语义藏在命名里,而计数会丢失这层信息。所有正则都用 (?:-webkit-)? 兼容带前缀写法,re.I 兼容大小写。

第 6 步:组装并打印

python
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 0
python
def 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。

数据模型

Loading diagram...

字段语义与来源:

字段类型来源用途
sourcestringCLI 参数回溯输入,URL 或本地路径
retrieval_headersmap(str→str)HTTP 响应头(小写化)或 {"path": ...}记录 Last-Modified / ETag / Content-Type 等抓取上下文
decoded_bytesintlen(body)gzip 解压后的字节数,用于与台账字节数比对
sha256stringhashlib.sha256(body).hexdigest()唯一指纹;台账靠它判定"资产是否变化"
colorslisttop(COLORER)归一化颜色的频次排名 → 提取主色/信号色
font_familieslisttop(FONT_COUNTER)归一化 font-family 栈的频次排名
font_faceslistFONT_FACE_RE 解析family + 最多 8 个折叠后的来源
featuresdict6 组计数 + keyframes 列表技术签名,区分"用了什么手段"而非"什么颜色"

使用示例

示例 1:分析仓库自带的 React 主题样式表(本地文件入口)

bash
python3 "$CODEX_HOME/skills/ark-ui/scripts/analyze-css-evidence.py" \ assets/react/ark-ui.css

本地文件分支会返回 {"path": "<绝对路径>"} 作为 retrieval_headers,适合在改动主题 CSS 前后各跑一次,用 sha256 快速确认"证据基线是否被自己改动"。

示例 2:研究一个新官方页面的生产样式(URL 入口)

bash
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:缩小输出列表

bash
python3 scripts/analyze-css-evidence.py assets/showcases/showcase.css --limit 10

--limit 10 会同时截断 colors、font_families、font_faces 与 features.keyframes 四个列表,用于快速浏览长尾不重要的场景。

示例 4:五组提取正则(提取层的全部输入约定)

python
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)或本地文件路径
--limitint30colors / font_families / font_faces / features.keyframes 各列表的最大条目数

硬编码的行为常量(非 CLI 可配置):

常量值位置
请求 User-Agentark-ui-evidence/1.0read_source 内
请求 Accept-Encodinggzipread_source 内
HTTP 超时30 秒urllib.request.urlopen(request, timeout=30)
top() 默认 limit30函数签名默认值(实际被 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 块缺失 familyfamily 置 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"——扩展时应维持这一"只提取、不评价"的边界。

相关链接

Sources

(3 files)