表单填到一半就没了:自动化里三类"页面自己变了"的坑
写自动化脚本最挫败的时刻,不是报错,是它跑完了、日志全绿、结果却没有。
页面没崩,选择器没失效,每一步都返回成功。但提交上去的东西是空的。
这类问题有个共同点:页面在你不知道的时候自己变了一次。下面三个都是实测踩过的,来自给一个带 SSO 的站点做登录后操作。每个坑后面附了怎么提前发现。
一、SSO 重定向把已填内容清空
场景:页面打开了,标题填了、正文填了、标签加了,点发布,弹窗提示"请填写标题"。
打开截图一看,编辑器是空的。
原因是 URL 里多了个东西:
?ticket=ST-1234-xxxx-cas
带票据的这次跳转是一次完整的页面加载。浏览器不会保留上一个文档里 DOM 表单的值——那些内容从来没进过后端,只活在内存里。页面一刷新,全没了。
麻烦在于这个跳转的时间点不固定。可能在你打开页面 3 秒后,也可能在你点了发布之后。看着像"发布失败",其实是"提交的根本不是你填的那份"。
怎么提前发现
在提交前,比对填完之后拿到的值,和 DOM 里现在的值:
const before = await page.locator('#title').inputValue();
// ...其它操作
const after = await page.locator('#title').inputValue();
if (before !== after) throw new Error('页面被重置,内容丢失');
更彻底的监测是监听导航事件,任何一次预期外的跳转都记下来:
let navs = [];
page.on('framenavigated', f => {
if (f === page.mainFrame()) navs.push({ url: page.url(), t: Date.now() });
});
跑完看 navs,如果中间多了一次带 ticket= 或 sso 的加载,基本就锁定了。
怎么办
两条路。一是等它跳完再填——但跳转时机不可控,这招不稳。二是绕开 DOM,直接调页面暴露的提交接口。带 SSO 的系统通常都有一层前端封装函数,翻一下页面源码往往能找到:
// 页面里已存在的原生函数,直接复用它的 CSRF 处理和字段组装
await page.evaluate((payload) => window.submitPost(payload), data);
这条路的前提是能读懂它的字段契约,后面第四节说。
二、编辑器不在你以为的那个文档里
第二个坑更隐蔽:选择器完全正确,locator.fill() 也不报错,但内容就是没进去。
因为编辑器在 iframe 里。
而且很多富文本编辑器用的是 CodeMirror 6,正文不在 textarea、不在 contenteditable 的普通 div 里,而在:
<div class="cm-content" contenteditable="true"></div>
它的内容由编辑器内部状态维护,直接设 innerText 会被下一次重渲染抹掉。
正确写法是先定位 frame,再聚焦,再用键盘输入:
const frame = page.frameLocator('iframe#editor-frame');
const body = frame.locator('.cm-content');
await body.click(); // 必须先获得焦点
await page.keyboard.insertText('正文…'); // insertText 比 type 快且不改触发顺序
insertText 会把整段文本作为一次输入事件写入,不像 type 逐字符模拟。对 CodeMirror 这类自己接管输入事件的组件,前者可靠得多。
判断是不是这类问题
一句话:fill() 成功了但页面预览没变化,就去看 iframe。
console.log(page.frames().map(f => f.url()));
如果正文所在的 frame 有一堆自己的子资源,说明渲染是独立的,主文档的选择器根本够不着它。
同一类坑还有"自定义勾选框":看着是个 checkbox,实际上是个 ,没有 type 属性,check() 直接报找不到元素。这种只能点它本身,然后靠下游按钮的状态变化来验证是不是真勾上了——比如发表按钮的 class 从 xxx-no 变成 xxx。
别信"我点了",只信"状态变了"。
三、MFA 静默拦截:看起来只是没登录
第三个坑最费时间,因为它不给任何错误。
流程是扫码登录,扫完,页面回到目标站点,一切正常。但页面显示的仍然是未登录状态:积分是 -,按钮是"去完成"。
第一反应是"扫码没成功"。其实成功了,卡在下一步:
https://auth.example.com/authui/login.html#/loginBindMfa
账号要求双因素验证,页面提示"账号未开启双验证",等你选"暂不开启"。选完才放行。
问题在于这个页面不报错、不弹窗、不跳转。它只是安静地停在那儿。而你的脚本已经往下走了,看到的是"目标页面加载成功",于是判定登录失败。
怎么判断状态
别用 URL 判断,URL 会骗人。用业务页面上的特征文本:
const state = await page.evaluate(() => {
const t = document.body.innerText || '';
return {
needLogin: /登录\/注册查看/.test(t),
user: (t.match(/\b[a-z]{2}_\d{6,}\b/) || [null])[0],
};
});
if (state.needLogin || !state.user) { /* 停下来,交还控制权 */ }
有用户名才算登录。needLogin 这类文案是 UI 的兜底显示,比任何接口返回都诚实。
拦截链要按顺序走完
登录流程往往是多级的:扫码 → 可能是 MFA → 可能是协议签署 → 才到目标页。每一步都会改变"登录态"的判定结果,所以要逐级处理,每级都截图落盘:
await page.goto('https://console.example.com/');
if (/loginBindMfa/.test(page.url())) {
await page.locator('input[type="checkbox"]').first().check();
await page.getByText('暂不开启', { exact: true }).click();
await page.waitForTimeout(8000);
}
waitForTimeout 在这里不是偷懒。协议页和 MFA 页的放行是异步的,没有可靠的 DOM 信号可以等,固定等待加一次校验是最实际的做法。
四、换个层次解决问题:读接口契约
上面三个坑,本质上都是"在 DOM 层做本该在请求层做的事"。
DOM 是给人用的,会重渲染、会被 iframe 隔离、会被中间页打断。而请求层是稳定的。
当页面里已经加载了一个完整的提交函数,最稳的做法不是模拟点击,而是搞清楚它的字段契约,直接调用:
// 1. 找到页面已经解析好的那个函数
const src = await page.evaluate(() => window.submitPost.toString());
// 2. 读它组装的字段:哪些必填、哪些是常量、CSRF 从哪来
// 3. 自己组一份等价的数据传进去
await page.evaluate((d) => window.submitPost(d), payload);
好处是:没有重定向清空问题(不起 DOM)、没有 iframe 问题(不碰编辑器)、错误码是结构化的(不是"发布失败"四个字,而是具体 code)。
代价是要花时间读源码,而且接口变了脚本会静默失效——所以要配合第一节的校验:提交后立刻拿到返回的 ID,再用这个 ID 去查一次。
const res = await page.evaluate((d) => window.submitPost(d), payload);
if (!res || res.code !== 'OK') throw new Error(`提交失败: ${JSON.stringify(res)}`);
const id = res.data.id;
有了 ID,后面的核对就有依据了。没 ID 的成功,都是猜测。
五、小结
三个坑,一个共同规律:
- SSO 重定向会清空 DOM 里的内容 → 提交前比对值,或监听
framenavigated - iframe + CodeMirror 让选择器"正确但无效" → 用
frameLocator,聚焦后insertText - MFA 静默拦截不报错只是不通行 → 用业务特征判断登录态,不要信 URL
- 页面会自我重渲染,只信状态变化,不信"我点了"
- 能读接口契约就别硬模拟 DOM,但接口调用必须配 ID 级校验
判断标准可以简化成一句:你的脚本有没有一个"提交之后能独立验证"的锚点。有,坑就只是麻烦;没有,坑就会变成"跑通了但不对",而后者更难查。
文中代码基于 Playwright + Windows 实测,站点已做脱敏处理。
- 点赞
- 收藏
- 关注作者
评论(0)