浏览器自动化 · 实战复盘

用 Playwright 打通知乎自动发文

把一篇稿子自动填进知乎专栏编辑器,走通「登录 → 进编辑器 → 写标题正文 → 存草稿」全链路。 路上有四道坎,每一道的报错信息都指向了错误的排查方向。

目标 知乎专栏编辑器 执行层 playwright-core 库 浏览器 系统 Chrome 154 · headless 结果 链路打通

00速览:三个必踩的坑,先说结论

下面三条是最花时间的。每一条的报错信息都把排查方向指偏了——这是它们真正危险的地方。

坑 1 · 版本错配

浏览器版本错配

Error: Target crashed

报错长得像环境问题,其实是 CLI 期望的浏览器 revision 与本地缓存对不上。

正解:用 channel: 'chrome' 走系统 Chrome,别用 bundled Chromium。

坑 2 · 凭据失效

二维码秒失效

手机提示:二维码已过期

扫码凭据绑定在会话上,浏览器一关,二维码当场作废。

正解:浏览器必须常驻轮询,不能截图后关掉。

坑 3 · 受控编辑器

Draft.js 不接受赋值

看着写进去了,字数始终为 0

正文是 Draft.js 受控编辑器,DOM 只是渲染结果,内部 state 才是真相。

正解:必须走真实输入事件 keyboard.insertText()。

还有一道更靠前的坎

在遇到上面三个坑之前,安装本身就会先被拒一次——但那是环境策略问题,不是 Playwright 的问题。见下一节。

01全链路总览

整条链路要穿过四道坎。每一道都对应一个具体的解法,也对应后文的一节。

1 装不上 --prefix $HOME/.local 装进用户目录 2 浏览器启动即崩 channel: 'chrome' 绕开版本错配 3 全站强制登录 canvas.Qrcode-qrcode 常驻轮询等扫码 4 Draft.js 不吃赋值 keyboard.insertText() 走真实输入事件
图 1 链路上的四道坎与一句话正解。四道坎的共同特征是:报错信息指向的方向,和真正的根因往往不是一回事。

02前置:Playwright 是什么

后面几节会反复出现三个名字相近、但不是一回事的东西。先分清它们,再看报错就不容易被带偏。

Playwright 是微软开源的浏览器自动化库,用一套统一 API 驱动 Chromium / Firefox / WebKit 三大渲染引擎。 2020 年 1 月发布,核心成员来自原 Google Puppeteer 团队;TypeScript 编写,Apache-2.0 许可, 官方绑定覆盖 TS/JS、Python、Java、.NET。

表 1 名字相近、但不是一回事的三个包
名字是什么当前版本
@playwright/cli 官方 CLI 封装 0.1.22(仍是 0.1.x 实验包,且锁定 alpha 内核)
playwright-core 纯客户端库 1.64.0(CLI 内部也带一份,本文实际调用的就是它)
@playwright/test 测试运行器 1.64.0(与上面不是同一个包)
你的代码 脚本 / 测试 / Agent daemon(仅 @playwright/cli 路径) 常驻会话层 —— 崩溃就发生在这里 CLI 独有的这一层 客户端库 playwright-core 把 API 调用翻译成浏览器指令 本文实际调用的是它 通信层 一条长连接 WebSocket,Chromium 走 CDP 浏览器进程 bundled Chromium 或系统 Chrome(channel)
图 2 Playwright 的调用栈。第二层 daemon 只存在于 CLI 路径——第 04 节那个 Assertion error 就抛在这一层; 库层直连没有这一跳,所以同一台机器上「库能跑、CLI 不能跑」。

浏览器还有两种来源,这正是下一节的核心:

bundled · 下载到本地缓存

由 playwright-cli install-browser 下到 ~/Library/Caches/ms-playwright/, 版本由 playwright-core 锁定——对不上就是下一节那个崩。

channel · 用系统已装的浏览器

channel: 'chrome' 直接用本机 Chrome,版本由你自己决定, 不受下载缓存与内核版本约束。

最后是操作对象的层级:Browser(浏览器进程)→ BrowserContext (隔离会话,各自独立 cookie 与登录态)→ Page(标签页)。 第 05 节的 launchPersistentContext('./.zprofile') 所做的,就是把这个 Context 落到磁盘、 不随进程销毁——登录态因此能跨进程复用。

03坎一:装不上

按官方姿势安装,第一条命令就被拒。

bash · 安装
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,装进用户目录:

bash · 解法
npm install -g @playwright/cli@latest --prefix "$HOME/.local"
# → ~/.local/bin/playwright-cli (v0.1.22)
关键点

npm prefix 对应的 bin 目录如果已经在 PATH 里,装完就能直接调用—— 不需要改环境变量,也不需要 sudo。动手前先确认这一点,能省掉一轮折腾。

04坎二:浏览器一启动就崩

CLI 装好了,跑第一个冒烟测试,浏览器进程起来了,页面却立刻崩。

bash · 冒烟测试
playwright-cli open https://example.com
### Browser `default` opened with pid 52335.
### Error
Error: Target crashed

换成官方支持的 --browser=chrome 也一样:

输出 · 换 chrome channel
Error: Assertion error
    at _CRSession._onMessage (.../coreBundle.js:36256:11)
这个报错极具误导性

它长得像环境问题,很容易让人去怀疑沙箱、权限、图形界面——并且会在这三个方向上各浪费一轮排查。 但真因是版本错配。

屏幕上看到的 真正的根因 Error: Target crashed 误判为 CLI 期望的浏览器 revision 是 1247,本机只有 1243 Error: Assertion error 误判为 同一个根因:bundled Chromium 在这台机器上根本不存在 字数统计 = 0 误判为 Draft.js 内部 state 未更新,DOM 上的变化是假的
图 3 三条报错、三次误判、以及它们真正的根因。前两条都出在 CLI 的启动链路上,第三条属于另一类问题(受控编辑器), 详见第 06 节。报错的字面意思,和根因的层级经常不在一处。

怎么定位:别猜,用最小复现矩阵

直接用 CLI 自带的那套库去测。playwright-cli 依赖的 playwright-core 就在它自己的 node_modules 里:

js · 最小复现矩阵
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]); }
}

结果一目了然:

表 2 最小复现矩阵——把配置逐项隔离后的结果
配置结果
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 节的调用栈图。

两条解法

解法 A · 把缺的浏览器补上

bash
playwright-cli install-browser

会下载 chromium_headless_shell-1247。适合希望完全跟随 CLI 默认行为的场景。

解法 B · 绕开 bundled Chromium

bash
playwright-cli open --browser=chrome \
  https://example.com

直接用系统 Chrome,不依赖缓存里的 revision。

本次的选择

走的是解法 B 的思路,并且直接下到库里调用——因为后续需要更细粒度的控制 (精确指定 channel: 'chrome'、控制 deviceScaleFactor 等),CLI 命令行给不了。

05坎三:知乎全站强制登录

打开知乎首页,第一件事就是被踢到登录页。

重定向链
https://www.zhihu.com                → 302 /signin?next=%2F
https://zhuanlan.zhihu.com/write     → 302 /signin?next=...%2Fwrite

未登录状态下,编辑器元素数量是 0(contenteditable 计数为 0),完全不可达。 而且知乎只给两条路:扫码(知乎 App / 微信)或 手机短信验证码 / 密码。

结论前置

登录是整条发文流程唯一的硬阻塞点,而且必须有真人参与——没有绕过的办法。

二维码为什么会「秒失效」

第一版实现是这样的:打开登录页 → 截取二维码 → 关闭浏览器 → 把图片拿给人扫。这个流程是错的。

错误做法:截图后就关浏览器 打开 zhihu.com/signin 截取二维码存成图片 关闭浏览器 用户扫码 ✗ 二维码已作废 正确做法:浏览器常驻,脚本自己轮询 打开 zhihu.com/signin 截取二维码存成图片 常驻轮询等待 最多 8 分钟 检测跳出 /signin ✓ 登录态落盘
图 4 两条路径的唯一差别在第三步:截完二维码之后,浏览器是关掉还是留着。 二维码绑定在会话上,浏览器一关,会话没了,二维码当场作废——人扫到的必然是一张死图。

正确做法是让浏览器常驻,脚本自己轮询:

js · 常驻轮询登录
// 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,不是 img

二维码的选择器是 canvas.Qrcode-qrcode,渲染在 canvas 上。 用 <img> 那套选择器会一无所获——这一步会让人以为"页面上没有二维码"。

调高 deviceScaleFactor

该 canvas 的 CSS 尺寸只有 138×138,截图出来太小不好扫; 设 deviceScaleFactor: 3 后拿到 414px,手机一扫就中。

登录态要落到持久化 profile

用 launchPersistentContext 指定一个固定目录,登录态就存下来了,后续进程直接复用,不用反复扫码:

js · 持久化上下文
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 条消息) 首页 - 知乎」。 之后换进程重开,登录态依然有效。

06坎四:Draft.js 不接受赋值

这是最容易被低估的一个坑——因为它不报错。

进入 zhuanlan.zhihu.com/write,先探测编辑器结构:

表 3 知乎写文章页的编辑器结构
元素选择器
标题textarea[placeholder="请输入标题(最多 100 个字)"]
正文div.public-DraftEditor-content(contenteditable="true")
上传input.UploadPicture-input(type=file)

标题是普通 textarea,fill() 一把过。但正文这个 div.public-DraftEditor-content 是 Draft.js——它维护着自己的内部 state,DOM 只是渲染结果。

写入方式 编辑器内部 结果 fill() / innerText DOM(渲染结果) 内部 State(真相) DOM 变了 State 没变 字数 = 0,提交为空 keyboard.insertText() DOM(渲染结果) 内部 State(真相) DOM 变了 State 也变了 字数 = 256,草稿已自动保存
图 5 Draft.js 维护着两层:外层是 DOM,内层是真正权威的 state。 直接赋值只改得到外层——页面看起来写进去了,但字数统计纹丝不动,提交时也是空的。 只有能同时触发内层的真实输入事件才算数。
这个坑最阴的地方

它不抛异常、不报错、DOM 检查也能通过。用 fill() 或改 innerText 之后, 你去读 innerText 确实能读到自己写的内容——一切看起来都对了。唯一的破绽是字数统计始终显示 0。

正确姿势是聚焦后走真实输入事件:

js · 真实输入事件
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 跟着更新」。

07完整链路

每个环节的做法与实测结果。

表 4 「登录 → 进编辑器 → 写标题正文 → 存草稿」全链路实测结果
环节做法结果
浏览器启动channel:'chrome' + headless + --no-sandbox✅
扫码登录截 canvas.Qrcode-qrcode + 常驻轮询 URL✅ 约 2 分钟
登录态持久化persistent profile 目录✅ 跨进程复用
编辑器可达zhuanlan.zhihu.com/write✅
标题写入textarea + fill()✅
正文写入Draft.js + insertText() + Enter✅ 256 字
草稿保存知乎自动,状态「刚刚 · 草稿」✅
点击发布—⛔ 未执行
一个说明:本次执行层用的是库,不是 CLI 命令行

本文记录的实践中,最终执行层用的是 playwright-cli 自带的 playwright-core 库,而非 CLI 命令行。 原因是第 04 节说的启动问题——直接调用库可以精确指定 channel: 'chrome' 并控制 deviceScaleFactor 等参数。 两者是同一套引擎,能力一致,只是入口不同。

08环境里还有两个小陷阱

不影响主流程,但都会让人在原地卡很久。

陷阱 1

CDP 常驻方案不通

本来想用 --remote-debugging-port=9222 起一个长期 Chrome,再用 connectOverCDP 反复连。实测 9222 端口无响应,走不通。

改用「单进程内轮询」不但更简单,效果还更好。

陷阱 2

进程残留会锁死 profile

Chrome 会锁住 user-data-dir,残留进程不清理,下次启动直接失败。 而在这个环境里 ps / pkill 都被限制(ps: operation not permitted), 查不到进程列表。

最后的办法是直接换一个全新的 profile 目录绕开锁冲突——比清进程靠谱。

09经验清单

如果只记五条,记这五条。