把一篇稿子自动填进知乎专栏编辑器,走通「登录 → 进编辑器 → 写标题正文 → 存草稿」全链路。 路上有四道坎,每一道的报错信息都指向了错误的排查方向。
下面三条是最花时间的。每一条的报错信息都把排查方向指偏了——这是它们真正危险的地方。
Error: Target crashed
报错长得像环境问题,其实是 CLI 期望的浏览器 revision 与本地缓存对不上。
正解:用 channel: 'chrome' 走系统 Chrome,别用 bundled Chromium。
手机提示:二维码已过期
扫码凭据绑定在会话上,浏览器一关,二维码当场作废。
正解:浏览器必须常驻轮询,不能截图后关掉。
看着写进去了,字数始终为 0
正文是 Draft.js 受控编辑器,DOM 只是渲染结果,内部 state 才是真相。
正解:必须走真实输入事件 keyboard.insertText()。
在遇到上面三个坑之前,安装本身就会先被拒一次——但那是环境策略问题,不是 Playwright 的问题。见下一节。
整条链路要穿过四道坎。每一道都对应一个具体的解法,也对应后文的一节。
后面几节会反复出现三个名字相近、但不是一回事的东西。先分清它们,再看报错就不容易被带偏。
Playwright 是微软开源的浏览器自动化库,用一套统一 API 驱动 Chromium / Firefox / WebKit 三大渲染引擎。 2020 年 1 月发布,核心成员来自原 Google Puppeteer 团队;TypeScript 编写,Apache-2.0 许可, 官方绑定覆盖 TS/JS、Python、Java、.NET。
| 名字 | 是什么 | 当前版本 |
|---|---|---|
@playwright/cli |
官方 CLI 封装 | 0.1.22(仍是 0.1.x 实验包,且锁定 alpha 内核) |
playwright-core |
纯客户端库 | 1.64.0(CLI 内部也带一份,本文实际调用的就是它) |
@playwright/test |
测试运行器 | 1.64.0(与上面不是同一个包) |
Assertion error 就抛在这一层;
库层直连没有这一跳,所以同一台机器上「库能跑、CLI 不能跑」。
浏览器还有两种来源,这正是下一节的核心:
由 playwright-cli install-browser 下到 ~/Library/Caches/ms-playwright/,
版本由 playwright-core 锁定——对不上就是下一节那个崩。
channel: 'chrome' 直接用本机 Chrome,版本由你自己决定,
不受下载缓存与内核版本约束。
最后是操作对象的层级:Browser(浏览器进程)→ BrowserContext
(隔离会话,各自独立 cookie 与登录态)→ Page(标签页)。
第 05 节的 launchPersistentContext('./.zprofile') 所做的,就是把这个 Context 落到磁盘、
不随进程销毁——登录态因此能跨进程复用。
按官方姿势安装,第一条命令就被拒。
npm install -g @playwright/cli@latest
直接被拒:
npm error code CODEBUDDY_BROKER_DENY
npm error ENOENT: no such file or directory, rename
'.../node/versions/22.22.2-6/bin/playwright-cli'
-> '.../node/versions/22.22.2-6/bin/.playwright-cli-PKNf4PUR'
安装目标落在了托管运行时(managed runtime)的二进制目录里,这个位置被沙箱策略保护,不允许写入。 跟 Playwright 本身没有关系,换任何全局包都会撞上同一堵墙。
解法是换个 prefix,装进用户目录:
npm install -g @playwright/cli@latest --prefix "$HOME/.local"
# → ~/.local/bin/playwright-cli (v0.1.22)
npm prefix 对应的 bin 目录如果已经在 PATH 里,装完就能直接调用——
不需要改环境变量,也不需要 sudo。动手前先确认这一点,能省掉一轮折腾。
CLI 装好了,跑第一个冒烟测试,浏览器进程起来了,页面却立刻崩。
playwright-cli open https://example.com
### Browser `default` opened with pid 52335.
### Error
Error: Target crashed
换成官方支持的 --browser=chrome 也一样:
Error: Assertion error
at _CRSession._onMessage (.../coreBundle.js:36256:11)
它长得像环境问题,很容易让人去怀疑沙箱、权限、图形界面——并且会在这三个方向上各浪费一轮排查。 但真因是版本错配。
直接用 CLI 自带的那套库去测。playwright-cli 依赖的 playwright-core 就在它自己的 node_modules 里:
const PW = '~/.local/lib/node_modules/@playwright/cli/node_modules/playwright-core';
const { chromium } = require(PW);
for (const opt of [
{ channel: 'chrome', headless: true, args: ['--no-sandbox', '--disable-gpu'] },
{ headless: true, args: ['--no-sandbox', '--disable-gpu'] },
]) {
try {
const b = await chromium.launch(opt);
const p = await b.newPage();
await p.goto('https://example.com');
console.log('OK', await p.title());
await b.close();
} catch (e) { console.log('FAIL', e.message.split('\n')[0]); }
}
结果一目了然:
| 配置 | 结果 |
|---|---|
channel: 'chrome' + headless |
✅ title="Example Domain",4.9s |
| bundled chromium(默认) | ❌ Executable doesn't exist at .../chromium_headless_shell-1247/... |
@playwright/cli@0.1.22 依赖 playwright-core@1.64.0-alpha-1790635538000,
它期望的浏览器 revision 是 1247,而本机 ~/Library/Caches/ms-playwright 里只有 1243。
版本对不上,浏览器启动即崩。
所以 playwright-cli 开箱不能用的原因,跟沙箱、权限、图形界面都无关,纯粹是浏览器二进制版本不匹配。
上面这条结论只对了一半。补装 chromium_headless_shell-1247 之后,
playwright-cli 命令行依旧报同一个 Assertion error,
而库层六种启动配置(bundled 与 channel: 'chrome'、带不带 --no-sandbox)全部正常。
也就是说:浏览器版本不匹配是真实存在的(当时 bundled Chromium 确实缺 1247,补装即修复), 但它不是 CLI 崩溃的根因——CLI 在调用栈上比库多一层 daemon,问题出在那里,见第 02 节的调用栈图。
playwright-cli install-browser
会下载 chromium_headless_shell-1247。适合希望完全跟随 CLI 默认行为的场景。
playwright-cli open --browser=chrome \
https://example.com
直接用系统 Chrome,不依赖缓存里的 revision。
走的是解法 B 的思路,并且直接下到库里调用——因为后续需要更细粒度的控制
(精确指定 channel: 'chrome'、控制 deviceScaleFactor 等),CLI 命令行给不了。
打开知乎首页,第一件事就是被踢到登录页。
https://www.zhihu.com → 302 /signin?next=%2F
https://zhuanlan.zhihu.com/write → 302 /signin?next=...%2Fwrite
未登录状态下,编辑器元素数量是 0(contenteditable 计数为 0),完全不可达。
而且知乎只给两条路:扫码(知乎 App / 微信)或 手机短信验证码 / 密码。
登录是整条发文流程唯一的硬阻塞点,而且必须有真人参与——没有绕过的办法。
第一版实现是这样的:打开登录页 → 截取二维码 → 关闭浏览器 → 把图片拿给人扫。这个流程是错的。
正确做法是让浏览器常驻,脚本自己轮询:
// 1) 打开登录页并截二维码
await page.goto('https://www.zhihu.com/signin');
const qr = page.locator('canvas.Qrcode-qrcode').first();
await qr.screenshot({ path: 'zhihu-qr.png' });
// 2) 常驻轮询:登录成功后页面会自动跳出 /signin
let ok = false;
for (let i = 0; i < 160; i++) { // 160 × 3s = 8 分钟
if (!page.url().includes('signin')) { ok = true; break; }
await page.waitForTimeout(3000);
}
二维码的选择器是 canvas.Qrcode-qrcode,渲染在 canvas 上。
用 <img> 那套选择器会一无所获——这一步会让人以为"页面上没有二维码"。
该 canvas 的 CSS 尺寸只有 138×138,截图出来太小不好扫;
设 deviceScaleFactor: 3 后拿到 414px,手机一扫就中。
用 launchPersistentContext 指定一个固定目录,登录态就存下来了,后续进程直接复用,不用反复扫码:
const ctx = await chromium.launchPersistentContext('./.zprofile', {
channel: 'chrome',
headless: true,
args: ['--no-sandbox', '--disable-gpu'],
viewport: { width: 1280, height: 900 },
deviceScaleFactor: 3,
});
二维码生成后约 2 分钟扫码完成,登录成功落到 https://www.zhihu.com/,
页面标题变成「(99+ 封私信 / 21 条消息) 首页 - 知乎」。
之后换进程重开,登录态依然有效。
这是最容易被低估的一个坑——因为它不报错。
进入 zhuanlan.zhihu.com/write,先探测编辑器结构:
| 元素 | 选择器 |
|---|---|
| 标题 | textarea[placeholder="请输入标题(最多 100 个字)"] |
| 正文 | div.public-DraftEditor-content(contenteditable="true") |
| 上传 | input.UploadPicture-input(type=file) |
标题是普通 textarea,fill() 一把过。但正文这个 div.public-DraftEditor-content
是 Draft.js——它维护着自己的内部 state,DOM 只是渲染结果。
它不抛异常、不报错、DOM 检查也能通过。用 fill() 或改 innerText 之后,
你去读 innerText 确实能读到自己写的内容——一切看起来都对了。唯一的破绽是字数统计始终显示 0。
正确姿势是聚焦后走真实输入事件:
const body = page.locator('div.public-DraftEditor-content').first();
await body.click();
await page.waitForTimeout(700);
for (const para of PARAS) {
await page.keyboard.insertText(para); // 触发 beforeinput/input,Draft.js 能捕获
await page.waitForTimeout(250);
await page.keyboard.press('Enter'); // 分段
await page.waitForTimeout(250);
}
实测结果:
状态: {
"wordCount": "256",
"draftState": "刚刚",
"titleValue": "自动化发文链路验证(测试稿 · 请勿发布)",
"bodyLen": 263
}
!!! 未点击发布,仅停留在编辑器 !!!
字数 256、草稿状态「刚刚」,说明内容确实写进了 Draft.js,知乎也自动保存了草稿。
上面这条结论适用于纯文本。如果要往编辑器里注入富文本——
标题层级、表格、代码块、列表——insertText() 不够用,它会把这些内容当纯文本落进去,
##、|、``` 会原样显示。
富文本的正确入口是合成一个 paste 事件,并把内容放在 text/html 里:
Draft.js 的粘贴处理器会解析 HTML 并转换成对应的 block 类型。实测 text/plain 粘贴无效(符号裸露),
text/html 粘贴则能正确生成二级标题、加粗、行内代码、代码块(含语法高亮)、表格与列表。
换言之:纯文本走 insertText(),富文本走 text/html 合成粘贴。
两者的共同前提都是「必须让 Draft.js 的内部 state 跟着更新」。
每个环节的做法与实测结果。
| 环节 | 做法 | 结果 |
|---|---|---|
| 浏览器启动 | channel:'chrome' + headless + --no-sandbox | ✅ |
| 扫码登录 | 截 canvas.Qrcode-qrcode + 常驻轮询 URL | ✅ 约 2 分钟 |
| 登录态持久化 | persistent profile 目录 | ✅ 跨进程复用 |
| 编辑器可达 | zhuanlan.zhihu.com/write | ✅ |
| 标题写入 | textarea + fill() | ✅ |
| 正文写入 | Draft.js + insertText() + Enter | ✅ 256 字 |
| 草稿保存 | 知乎自动,状态「刚刚 · 草稿」 | ✅ |
| 点击发布 | — | ⛔ 未执行 |
本文记录的实践中,最终执行层用的是 playwright-cli 自带的 playwright-core 库,而非 CLI 命令行。
原因是第 04 节说的启动问题——直接调用库可以精确指定 channel: 'chrome' 并控制 deviceScaleFactor 等参数。
两者是同一套引擎,能力一致,只是入口不同。
不影响主流程,但都会让人在原地卡很久。
本来想用 --remote-debugging-port=9222 起一个长期 Chrome,再用
connectOverCDP 反复连。实测 9222 端口无响应,走不通。
改用「单进程内轮询」不但更简单,效果还更好。
Chrome 会锁住 user-data-dir,残留进程不清理,下次启动直接失败。
而在这个环境里 ps / pkill 都被限制(ps: operation not permitted),
查不到进程列表。
最后的办法是直接换一个全新的 profile 目录绕开锁冲突——比清进程靠谱。
如果只记五条,记这五条。
Target crashed 看着像环境问题,实际是版本错配。用「最小复现矩阵」把配置逐项隔离,比顺着错误信息猜快得多。