Playwright 自动化后台操作:七个「脚本说成功、其实没成功」的坑

用 Playwright 写后台自动化,最难的不是写不出脚本,是脚本会告诉你它成功了。

我这段时间给几个内容平台写发布脚本,外加给自己那个静态站写巡检和构建校验。跑通的脚本一共不到 300 行,但中间被骗了七次。每一次的表现都是「命令退出码 0、日志一片正常」,实际什么都没发生,或者发生了完全相反的事。

这七个坑的共同点:都不是 Playwright 的 bug,是我把「没报错」当成了「成功」。


一、判据错了:HTTP 200 和「页面看起来正常」都是假信号

第一个坑最贵。

我要判断一个后台账号是否开通了某项功能。做法是打开管理页,看返回状态码和页面标题:

const res = await page.goto(adminBase + '/posts');
if (res.status() === 200) {
  console.log('已开通');   // ← 这就是错的
}

它确实返回 200,标题正确,左侧导航、列表容器、搜索框全都渲染出来了。然后我据此宣布「已开通」,实际那个账号根本没开通。

真相是:这个后台是 Angular 单页应用。未开通时,前端依然会把管理界面完整渲染出来,然后在上面盖一层全屏遮罩(.cdk-overlay-backdrop),遮罩中间显示一行字「您的账号未开通」。

所以正确的判据不是状态码,是渲染完成之后的页面文字:

const blocked = await page.evaluate(() => {
  const t = document.body.innerText || '';
  return t.includes('未开通') ? t : '';
});

这里有个细节:innerText 要等 SPA 渲染完才有内容。waitUntil: 'domcontentloaded' 之后立刻读,读到的是空壳。必须等一个明确的信号——要么 networkidle,要么显式等某个元素出现。

通用法则:状态码只能证明「服务器响应了」,页面文字才能证明「用户看到了什么」。 后台类页面尤其如此,越现代的前端框架,状态码越没有信息量。

二、「元素可见」不等于「能点到」

紧接着的第二个坑,是上一坑的下游事故。

遮罩存在的时候,Playwright 的 locator.click() 会卡住。它内部有一套可操作性检查(visible、stable、receives events),遮罩挡住了点击落点,它就慢慢等,等到超时。

问题在于报错信息完全没有指向遮罩:

locator.click: Timeout 30000ms exceeded.

三十秒后只告诉你超时。你会先怀疑选择器写错了,然后怀疑元素还没渲染,然后去加 waitForTimeout——方向全错。

诊断这件事只需要一行,但我当时没想到:

// 点击坐标 (x, y) 上真正接住事件的是谁?
const truth = await page.evaluate(([x, y]) => {
  const el = document.elementFromPoint(x, y);
  return el ? el.className + ' | ' + (el.innerText || '').slice(0, 50) : 'null';
}, [x, y]);

如果返回的 className 是 cdk-overlay-backdrop 之类,答案就出来了:元素在,但上面压着东西。

更省事的办法是直接扫一遍全屏浮层:

const overlays = await page.evaluate(() =>
  Array.from(document.querySelectorAll('div,section'))
    .filter(el => {
      const r = el.getBoundingClientRect();
      const cs = getComputedStyle(el);
      return r.width > window.innerWidth * 0.8 && r.height > window.innerHeight * 0.8
             && (cs.position === 'fixed' || cs.position === 'absolute');
    })
    .map(el => ({ cls: el.className, text: (el.innerText || '').slice(0, 80) }))
);

教训:locator.click 超时,九成不是选择器问题,是有东西压在上面。 别加等待,先查遮挡。

三、富文本编辑器不要用键盘输入

这一条坑得最隐蔽,因为它不报错。

我给一个平台的编辑器填正文,选择器是 textarea.byte-input__textarea,fill() 执行成功,inputValue() 读回来也对——一切正常。但页面上正文是空的,点发布直接报「内容不能为空」。

原因是这个 textarea 根本不是编辑器,它是隐藏的输入法辅助元素:宽度 0、opacity: 0,专门用来接中文输入法的 composition 事件。真正的内容存在 CodeMirror 的 JS 实例里,DOM 上那层是渲染出来的假象。

对这类编辑器,唯一可靠的办法是直接调它的 API:

await page.evaluate(text => {
  const cm = document.querySelector('.CodeMirror');
  cm.CodeMirror.setValue(text);       // CodeMirror 5 的实例挂在 DOM 节点上
}, body);

如果用的是 CodeMirror 6 或 wangEditor,API 位置不一样(CM6 需要从 EditorView 拿,wangEditor 挂在 editor 实例上),但思路一致:找到编辑器实例,调它的设置内容方法,不要模拟键盘。

判断一个 textarea 是不是「假输入框」很快:看它的 offsetWidth 和 opacity。宽 0 或者完全透明,基本可以确定它不是给人用的。

四、启动参数会被当成 URL

这个坑纯粹浪费半小时,但很典型。

chromium.launch({ args: ['--window-position', '60,20'] });   // ✗

Chromium 拿到这个参数数组后,把 60,20 当成了一个要打开的目标,于是直接报导航错误,连浏览器都没起来。正确写法是把值用等号接上:

chromium.launch({ args: ['--window-position=60,20'] });      // ✓

规律是:所有 Chromium 命令行开关,值必须用 = 连接在同一个数组项里。 拆成两项就等于给它喂了一个裸参数,而裸参数会被解释成 URL 或开关。

同理,--window-size=1440,1000、--proxy-server=http://... 都一样。

五、登录态不是文件,是会话

两个平台给了我完全相反的答案,这件事值得单独说。

平台 A:登录成功后把 token 写进 localStorage,关浏览器再开还在。它的登录态是可以持久化的,用 launchPersistentContext(profileDir) 挂一个用户目录,下次直接是登录状态。

平台 B:登录 cookie 是会话级的,浏览器一关就没了。用户目录里留着也没用,每次跑脚本都必须重新登录一遍。

如果按 A 的经验写 B 的脚本,表现是:第一次跑成功,隔天再跑,脚本在后台首页找不到「新建」按钮,超时。而报错依然只说超时。

所以每个平台的脚本开头都得有一条明确的会话检查 + 重登分支:

await page.goto(signinUrl);
const u = await page.$('input[placeholder*="用户名"]');
if (u) {
  await u.fill(cred.email);
  await page.locator('input[type="password"]').first().fill(cred.password);
  await page.locator('button:has-text("登录")').first().click();
}

判断方式用「登录表单在不在」,不要用 URL 或者某个叫 isLogin 的变量——表单在就登录,不在就跳过。这一条对 A、B 两类平台都成立。

顺带一句:别把用户目录当成登录态的保证。 它能保住 cookie,保不住服务端的会话有效期。真正的保证只有「进去之后检查一下当前是什么页面」。

六、点完不要立刻相信:三个断言

写在最后,因为这三条是前面所有坑的总结。

断言一:填完回读。

const got = await page.inputValue('#md-editor');
if (got !== body) {
  console.error('回读不一致,中止,未点发布');
  process.exit(1);
}

逐字符比对,不一致就退出。这一步拦住的是「内容没写进去但流程继续走」,如果不拦,会在发布那一步得到一句莫名其妙的「内容不能为空」,然后回头查一个根本不存在的校验问题。

断言二:结果页要有成功标志。

发布成功之后,平台的跳转通常会带上明确标记,比如 /edit-done;postId=12345;isPublished=true。要拿这个标记做判断:

const m = page.url().match(/postId=(\d+)/);
const ok = /isPublished=true/i.test(page.url());

只判断「URL 不再是编辑页」是不够的——跳到错误页、跳到登录页、跳到首页,都满足这个条件。

断言三:同一个文案的按钮可能有两个。

有一次点「发布」,弹出一个选择发布通道的浮层,浮层底部还有一个也叫「发布」的按钮。脚本按文本找按钮:

page.locator('button:has-text("发布")').first().click();   // 点到的是顶部那个

于是它一直在点顶部按钮,弹层反复打开又关掉,脚本认为点击成功了,循环三十秒什么都发不出去。

两个解法,选一个:

  • 按位置取:所有文案为「发布」的可见元素里,取 y 最大(最靠下)的那个。浮层按钮永远在下面。
  • 按容器取:先定位浮层容器,再在容器内查找。

我最后用的是第一个,虽然笨,但不用猜 DOM 结构,改版也不容易坏。

七、这套东西不适合什么场景

说清楚边界比推荐工具更有用:

  • 有 API 就别用浏览器。 上面七个坑里至少有五个是「在跟渲染层较劲」。有接口直接请求接口,一行代码顶一百行模拟点击。
  • 一次性任务不值得写脚本。 手动点十次就完事,写脚本加调试要两小时。
  • 需要高频跑、且页面结构稳定的流程才划算。 我这几个脚本的价值在于「每周要重复几十次」,且平台后台两年没大改版。

还有一条更实际的:给别人的平台写自动化,先看清楚它的用户协议。 有些平台明确禁止自动化操作,写完也是白写。

结尾

七个坑,没有一个涉及复杂的编程技巧。全是判断标准的问题:我把「没抛异常」「返回 200」「没超时」「URL 变了」当成了成功。

真正可靠的信号只有三个——页面上有对应文字、字段里是预期内容、结果页有明确的成功标记。其余都是猜测。

我的做法是把这三条写成三个 if,每个都 process.exit(1)。脚本因此变得很爱报错,但报的每一个错都是真的。

这批脚本服务的那个站是纯静态的,构建、跳转、校验各有一段自己的检查逻辑,代码都在站上:

https://ai-tool-scenes.pages.dev

posted @ 2026-10-22 18:03  觉醒笔记  阅读(10)  评论(0)    收藏  举报