编程 Playwright 与 Headless 浏览器:把视觉验收变成断言,把 JS 签名交给浏览器算

2026-09-14 00:04:19

Playwright 与 Headless 浏览器:把视觉验收变成断言,把 JS 签名交给浏览器算

文中涉及的工具与协议:PlaywrightChromiumCDP 协议文档obfuscator.ioplaywright-extra原文出处

基础:Headless 浏览器与 Playwright 的分层关系

Headless 浏览器就是把 UI 壳(地址栏、标签页、窗口)去掉的真实浏览器,只留渲染引擎。它不是模拟器——JS 照常执行(WebGL、Canvas、Promise 全支持),CSS 动画照常跑,网络请求照常发,Web API 全部可用。唯一的区别是不把像素输出到屏幕。

发一个“点击坐标 (320,480)”的指令,Chrome 会自己做 hit-test、走完整事件链(mousedown→mouseup→click→触发监听器)、响应 JS 修改 DOM,和人类用鼠标点对浏览器内部来说是同一件事。

Chrome Headless 暴露了 Chrome DevTools Protocol(CDP),本质是一个 WebSocket,你发 JSON 命令它执行并返回:

{"method":"Page.captureScreenshot","params":{"format":"png"}}
{"method":"Runtime.evaluate","params":{"expression":"document.fonts.status"}}

Playwright(Microsoft 出品)把 CDP 及 Firefox/WebKit 的类似协议包装成高层 API,解决自动等待、跨浏览器、浏览器版本管理等问题。

Chromium vs Chrome:Chromium 是开源的渲染引擎毛坯;Chrome 在 Chromium 基础上加了 Widevine DRM、自动更新等闭源组件。渲染引擎(Blink)和 JS 引擎(V8)都在 Chromium 里。自动化测试和爬虫只需要渲染和 JS 执行,Playwright 默认下载 Chromium 而非 Chrome。

Playwright 下载的还是 chrome-headless-shell——专门为 headless 场景编译的精简二进制,移除了所有 UI 代码。它装在 ~/.cache/ms-playwright/chromium-XXXX/,版本号编进路径,不同项目的 Playwright 版本各用各的 Chromium,互不干扰,清了重装一行命令(playwright install chromium)。

场景一:前端效果验收

给博客做 Mermaid 渲染修复、全站 3D 改造,每轮迭代都需要验证视觉效果是否正确——但“看起来应该没问题”不算修好。

为什么不能靠人眼或多模态识别:文字被裁 1–2px,截图放大前几乎看不出来;对比度不够,也要放大才跳出来。多模态图像识别可以做宏观确认,但做不了精确断言。正确做法:截图用来宏观确认,DOM 数值用来精确断言。

const textWidth = el.querySelector('.label').getBoundingClientRect().width;
const shapeWidth = el.querySelector('rect').getBoundingClientRect().width;
// textWidth > shapeWidth → 越界,精确到像素

对比度问题也是算出来的:用 ITU-R BT.601 luma 加权(0.299R + 0.587G + 0.114B)算填充色亮度,浅色填充就把 label 换深色。

无头 Chromium + CDP 作为验证闭环:Playwright 装完后,它的浏览器缓存里有 headless shell 二进制。可以直接启动它、用 Node.js 内置 WebSocket 直连 CDP,零 npm 依赖:

// 开 tab
const res = await fetch('http://127.0.0.1:9333/json/new?about:blank', { method: 'PUT' });
const ws = new WebSocket((await res.json()).webSocketDebuggerUrl);
// 检查渲染状态
// Runtime.evaluate → document.querySelectorAll('.mermaid')
// Runtime.evaluate → el.getAttribute('data-processed')
// Runtime.evaluate → document.fonts.status
// 按元素坐标精确截图
// Page.captureScreenshot + clip → getBoundingClientRect

这套闭环把“图好不好看”变成可观测的问题:data-processed 告诉你渲染有没有发生,截图告诉你渲染出来长什么样,DOM 数值告诉你几何是否正确。Mermaid 修复中正是用 Network.setCacheDisabled 禁缓存模拟“第一次进入”、循环跑 30 次来验证冷加载下的稳定性。

场景二:爬虫

传统爬虫(requests + BeautifulSoup)发 HTTP 请求→拿 HTML→解析,在服务端渲染(SSR)时代没问题。现代网站普遍用客户端渲染(CSR),同一套 API 给网页/iOS/Android 共用。浏览器 GET /articles 拿到 200 空壳 HTML(只有 div#app),再 GET /main.js,V8 执行 JS,GET /api/articles 拿 JSON,JS 渲染进 DOM 内容才出现。传统爬虫拿到的是空壳。

直接调 API 不是更好?理论上是——更快更稳更省资源。但现实里有几道坎,签名参数最常见:

GET /api/feed?user_id=12345&page=1&ts=1718345600&sign=a3f8c2d9...
// JS 里的签名逻辑(简化版)
const params = { user_id: 12345, page: 1, ts: Date.now()/1000|0 };
const keys = Object.keys(params).sort(); // 参数名排序
const str = keys.map(k => `${k}=${params[k]}`).join('&');
const sign = md5(str + '&key=AbCd1234Secret');

服务器用同样算法重算比对 sign,不一致就 403。ts 是当前时间戳,服务器校验时效:

if abs(time.time() - ts) > 60: return 403

你抓到的请求五分钟后重发直接被拒。

JS 混淆分三层:

第一层重命名:函数名、变量名全变成 _0x2f1c_0x4e5d。工具可自动重命名破掉这层。

第二层字符串加密:字符串抽出来放进加密数组,运行时解密,用索引引用。静态搜索 "secret" 找不到。

var _s = ['\x6d\x64\x35', atob('c2VjcmV0XzIwMjQ='), ...];
// 代码里全是 _fn(_s[0x1f1], _s[0x1f2])

第三层控制流平坦化:执行顺序由运行时字符串决定。

var _state = '3|1|0|2|4'.split('|');
var _idx = 0;
while (true) { switch (_state[_idx++]) {
  case '0': a = x * 2; continue;
  case '1': b = a + 1; continue;
  case '2': return b * b;
  case '3': _idx = 0; continue;
} }

真实混淆器(如 obfuscator.io)会嵌套十几层,state 字符串本身也加密。与 Java 字节码不同——Java 字节码有固定语义约束,反编译工具能还原;JS 混淆没这个约束,while+switch 语义上和顺序执行完全等价,但对工具和人眼都是灾难。

成本粗估:

  • 重命名 → 工具自动重命名 10 分钟;
  • 字符串加密 → 动态执行还原 1 小时;
  • 控制流平坦化 → 手工追踪或写专用反混淆器 几天到几周;
  • 多层嵌套 → 可能直接放弃。

Playwright 的破解思路:站在出口等。 不逆向算法,让浏览器替你算,你只拦截出口:

page.on('response', async (response) => {
  if (response.url().includes('/api/articles')) {
    const data = await response.json(); // 原始 JSON,干净无噪音
    save(data);
  }
});
await page.goto('https://example.com/articles');

签名算法再复杂也无所谓——Chromium 帮你跑完所有 JS、混入 Canvas 指纹、算好 sign、发出合法请求,你不需要知道中间发生了什么。数据从 API 服务器原路返回的 JSON 直接落到手里,不经过 HTML 解析,结构稳定,不会因为网页改版失效。

Headless 还是有头 Chrome:反爬检测的取舍

决定用 Playwright 之后还有一个选择:用 Headless Chromium,还是直接弹出真实 Chrome 窗口?

遇到强反爬时,有时 agent 会故意改成启动本机安装的真实 Chrome:

const browser = await chromium.launch({
  channel: 'chrome', // 用本机 Chrome,不用 Playwright 自带的 Chromium
  headless: false,   // 有头模式,弹出真实窗口
});

原因是 Headless 模式有可被检测到的特征:navigator.webdriver === true、Canvas 指纹异常、字体列表与正常浏览器不符……反爬系统检测到这些就拒绝或跳验证码。

在服务器上这条路走不通。Chrome 启动时依赖图形环境(Display Server)渲染窗口,服务器通常没有,强行启动会报错:

Error: Failed to launch the browser process
...no display environment variable specified

服务器上的两条出路:

  1. Xvfb 虚拟帧缓冲apt install xvfbXvfb :99 & DISPLAY=:99 chrome。Chrome 以为自己有显示器,指纹与有头版基本一致。
  2. Headless + stealth 插件playwright-extra 抹掉 webdriver 特征。无需虚拟显示器,能否过检测需实测。

Xvfb 的思路和 Headless 异曲同工——都是“没有真实显示器也能跑”,区别是 Xvfb 伪造一个虚拟显示器让 Chrome 以为有,Headless 是 Chrome 自己知道没有但不依赖。

推荐文章

程序员茄子在线接单