做静态站最容易被忽略的一步,是真正看它一眼。我们的流水线里,一个页面"完成"的标准长期是:标签配平检查通过、所有本地资源返回 200、部署后 URL 200。这三项全绿,我们就会认为页面没问题——直到某天有人截图回来,发现移动端上半屏是空的。
问题的本质是:标签配平和资源 200 只证明了文件的存在性,不证明渲染结果的正确性。CSS 特异性打架、媒体查询没生效、入场动画永远停在半透明、某个绝对定位元素把页面撑宽 40px——这些都不会让任何一条 HTTP 状态码变红。
本文记录我们在这台没有 Playwright、没有 puppeteer、agent-browser 还偶尔卡死的 Windows 机器上,把"看起来对不对"变成一个可判定结果的全过程。所有参数、报错和数值都来自本机实测。
静态站大多用 /assets/css/style.css 这类根绝对路径引用资源。直接 file:// 打开本机 HTML,所有绝对路径都会失效,页面会退化成无样式的白板——这时候截图看到的一切都是假的。
cd <站点目录>
<python> -m http.server 8900 --bind 127.0.0.1
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8900/ # 必须 200
服务起来之后再截图,看到的人才真的是"用户会看到的东西"。这一步不能省。
本机唯一可用的浏览器是 Edge:C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe。基础命令长这样:
"$E" --headless=new --disable-gpu --hide-scrollbars \ --no-first-run --no-default-browser-check \ --force-prefers-reduced-motion \ --user-data-dir="C:\Users\Administrator\AppData\Local\Temp\es_验证名" \ --window-size=1440,900 --virtual-time-budget=9000 \ --screenshot="C:/绝对路径/shots/out.png" "http://127.0.0.1:8900/"
看起来很正常,但它有三个坑,每一个都会让你白等半天。
命令执行完立刻检查文件,会发现文件根本还没写出来——而且是随机性的,有时有有时没有,非常像"玄学"。必须轮询等待:
for i in $(seq 1 30); do [ -f shots/out.png ] && break; sleep 2; done
--screenshot" 的路径要用正斜杠绝对路径写成 "...\shots\\$n.png" 这种反斜杠拼接变量的形式,会出现一个名为 shots$n.png 的垃圾文件——变量没被展开,反斜杠被当成转义。稳定写法是全部用正斜杠,从盘符开始写绝对路径。
实测两个实例共用同一个 --user-data-dir 时会互相锁住,结果只有先启动的那个能出图。规则很简单:一次调用只截一张;要多张就分多次调用,或者每张之间 sleep 12+ 并给每个实例不同的 --user-data-dir。
命令A && 命令B & 命令C。& 会把 A && B 整段丢进子 shell,前台 shell 的当前目录并不改变,于是后面用相对路径的 cp / rm 会操作到错误目录。我们曾因此在"临时改样式、截完还原"的流程里留下了一个未还原的样式表,还报了一个 No such file or directory,排查了很久。凡是涉及文件写入的命令,一律用绝对路径。这是本文最值钱的一条。想验证移动端,第一反应是 --window-size=390,9000。但 Windows 的窗口有最小宽度限制,这个参数实际会被强制到约 500px 的视口宽度。于是:
390px 宽度取的是这 500px 渲染的左边一截,右边被裁掉;我们就据此误报过一个根本不存在的 bug——查了半天 CSS,最后发现是截图工具的限制。
要让浏览器真的按 390px 渲染,得让内容待在一个 390px 宽的容器里。用一个临时页面加载同源 iframe 即可:
<!-- _mview.html,放在站点目录,验证完删除 -->
<!DOCTYPE html><html><head><meta charset="utf-8">
<style>html,body{margin:0;background:#000}
iframe{width:390px;height:8000px;border:0;display:block}</style>
</head><body><iframe src="/index.html"></iframe></body></html>
然后用 --window-size=500,8000 去截这个页面,再用 PIL 裁 (0,0,390,H),就得到一份真实 390px 视口的渲染。关键点在于 iframe 内部的媒体查询按 iframe 宽度生效,100vh 也按 iframe 高度计算——所以窄屏断点、字号、栈式布局全部是真的。
PIL 用我们环境里已装好的那个解释器:~/.workbuddy/binaries/python/envs/ar_env/Scripts/python.exe。
首页的英雄区常写 min-height:100vh。用一个 7000px 高的窗口去截整页,英雄区会直接占满整张图,下面所有内容都被推到很远的地方,看长图的人会以为"页面就这一屏"。
办法是临时把 100vh 换成固定值,截完立刻还原并校验:
S="C:/绝对/站点"; cp "$S/assets/css/style.css" /tmp/sb.css sed -i 's/min-height:100vh/min-height:720px/' "$S/assets/css/style.css" # ... 截图 ... cp /tmp/sb.css "$S/assets/css/style.css" grep -c "min-height:100vh" "$S/assets/css/style.css" # 应为 1(还原成功)
注意这里全部用绝对路径。清理与还原是这套流程里最容易被忽略、后果又最麻烦的一步:临时改动的样式如果没还原,就会被部署上线。所以收尾一定要有一条可核验的命令(比如 grep -c 返回预期值),不要靠"我记得还原了"。
滚动显现效果一般这么写:元素初始 opacity:0,进入视口后由 IntersectionObserver 加类名,CSS transition 负责过渡。无头环境里虚拟时间几乎不推进 transition,截图就停在半透明甚至完全透明的中间态。
两步解决:
/* 1) 命令行加 --force-prefers-reduced-motion
2) CSS 里写好对应媒体查询,注意特异性要够 */
@media (prefers-reduced-motion: reduce){
*{animation:none!important;transition-duration:.01ms!important}
html.js .reveal{opacity:1;transform:none}
}
第二条括号里的"特异性要够"是踩出来的:如果页面里写的是 html.js .reveal{opacity:0},而你的覆盖规则只写 .reveal{opacity:1},那么 html.js .reveal 的特异性更高,照样把你压回去,截图还是空的。覆盖规则必须用同级或更高的选择器。
同理,数字滚动动画也会骗截图:如果 HTML 里初始值是 0、由 JS 递增到真实值,那么截图永远停在中间态。我们曾想截一个"27"的统计数字,截出来的是"4"。正确做法是把真实值直接写在 HTML 里,JS 只负责递增的视觉效果。
"页面被撑宽了"最大的麻烦是不知道是谁撑的。靠肉眼看 CSS 猜效率极低,正确做法是让浏览器自己报出越界元素:
<!-- _diag.html,验证完删除 -->
<div id="out">pending…</div>
<iframe id="f" src="/index.html"
style="width:390px;height:150px;border:2px solid red"></iframe>
<script>
document.getElementById('f').addEventListener('load', function () {
var d = this.contentDocument, vw = 390, bad = [];
d.querySelectorAll('body *').forEach(function (el) {
var r = el.getBoundingClientRect();
if (r.width > 0 && (r.right > vw + 1 || r.left < -1))
bad.push(el.tagName + '[' + el.className + ']' +
' R' + Math.round(r.right) + ' W' + Math.round(r.width));
});
document.getElementById('out').textContent =
'SCROLLW=' + d.documentElement.scrollWidth +
' CLIENTW=' + d.documentElement.clientWidth +
' BADCOUNT=' + bad.length + '\n' + bad.slice(0, 14).join('\n');
});
</script>
把结果用大字号渲染出来截图读取即可。判据只有一行:
SCROLLW == CLIENTW 且 BADCOUNT == 0 → 无横向溢出。反之,输出里会直接列出越界元素的标签名、类名、右边界和宽度,顺着去查那个类就行。
顺带一个环境事实:本机 Edge 的 --dump-dom 在新旧两种无头模式下都没有输出,别指望用它把 DOM 打出来分析,诊断结果只能靠截图看。
from PIL import Image
im = Image.open(r'C:/.../full.png').convert('RGB')
im.crop((0,900,1400,2500)).save(r'C:/.../p1.png') # 按段裁
im.crop((0,0,390,1000)).resize((624,1600)).save(...) # 移动端放大看细节
# 找内容真实底部(避开纯黑背景)
px = im.load(); W,H = im.size; last = 0
for y in range(H-1,0,-3):
if any(px[x,y][2] > 26 or px[x,y][0] > 14 for x in range(0,W,10)):
last = y; break
最后那个"找内容底部"的小循环很实用:长图底部往往是一大片背景色,肉眼分不清"页面就到这"还是"下面被裁了",用一个亮度阈值扫一遍就能确认页面真实高度。
截图之外,标签配平和"HTML 里出现但 CSS 未定义的类名"仍然值得跑一遍——拼写错的类名不会报错,只会静默失效,这类问题截图也很难看出来。
但校验脚本里做"资源存在性"检查时要先剥掉 #锚点,否则 /products.html#service 这种会被误报成文件缺失,白跑一轮排查。这是纯字符串处理问题,一句话就能修。
| 动作 | 为什么必须做 |
|---|---|
删除 _mview.html / _diag.html / 截图目录 | 临时文件会被一起部署上线,污染站点 |
| 校验临时改过的 CSS 已还原 | grep -c "min-height:100vh" 回到原值,防"临时改动"上线 |
| 只杀自己起的无头实例 | 按命令行含 --headless 过滤,不要误杀正在使用的浏览器 |
关掉本地的 http.server | 不留后台尾巴,占用端口、干扰后续验证 |
这四条看起来琐碎,但前两条是"会污染生产"的级别,后两条是"会干扰下一次工作"的级别。我们把它们固化成了收尾脚本,每轮验证结束自动跑。
起本地服务 → 轮询式 Edge 截图(正斜杠绝对路径、一次一张)→ 移动端用 iframe 固定 390px 模拟视口 → 入场动画用 reduced-motion 绕过 → 溢出用同源 iframe 诊断页判定 → PIL 分段裁剪细看 → 清理临时文件并还原 CSS。
这套方法的通用价值在于:它把"我觉得看起来还行"换成了三条可复现的判据——截图存在且非半透明、SCROLLW 等于 CLIENTW、越界元素数为 0。只要判断标准是数字,验证就可以自动化,也可以交给别人复核。对内容站、工具站这类上线频繁的静态项目,这是防止"肉眼漏看"最便宜的一道闸门。
我们站内在售的方言语料资产(苏州话标准词库 3550 条)与数字人素材,也都是走同一套"先验证再交付"的流程做出来的——附字段说明与示例代码,交付即可用。
需要有人帮你做这份体检?看技术服务 → 看看站内已上架的数字资产 →因为 Windows 的窗口有最小宽度限制,--window-size=390 实际会被强制到约 500px 的视口宽度。于是截出来的图右侧被裁掉,你会把"截图被裁"误判成"页面横向溢出",我们据此误报过一个根本不存在的 bug。正确做法是用一个固定 390px 宽的同源 iframe 加载目标页面,再用 PIL 把图裁成 (0,0,390,H),得到真实 390px 视口的渲染。
两个常见原因。一是用了旧的 --headless(本机实测不出文件),必须用 --headless=new。二是 Edge 异步写文件,命令返回不等于文件已落盘,必须轮询等待。另外 --screenshot 的路径要用正斜杠绝对路径,用反斜杠拼接变量会生成名为 shots$n.png 的垃圾文件。
多半是入场动画。滚动显现常用 opacity:0 + IntersectionObserver + transition,无头环境里虚拟时间几乎不推进 transition,截图就停在半透明态。解决办法是加 --force-prefers-reduced-motion,并写好 prefers-reduced-motion 的 CSS 覆盖规则;注意覆盖选择器的特异性要够(用 html.js .reveal 而不是 .reveal),否则会被页面原有的 html.js .reveal{opacity:0} 压住。
不要靠猜。建一个诊断页,用同源 iframe 加载目标页,遍历 body 下所有元素,用 getBoundingClientRect 找出 right 超过视口宽或 left 小于 0 的元素并列出。判据是 documentElement.scrollWidth == clientWidth 且越界元素数为 0。本机 Edge 的 --dump-dom 新旧无头模式都没有输出,结果要靠截图读取。
多个 Edge 实例共用同一个用户数据目录会互相阻塞,实测只有第一个能成功出图。要么一次只截一张,要么每张之间间隔 12 秒以上并给每个实例指定互不相同的 --user-data-dir。另外不要把 cd 和 & 混在同一条命令链里:& 会把前半段放进子 shell,前台目录不变,导致后续相对路径的 cp/rm 操作错目录。