从 Playwright 到 CDP:一次语雀文档同步爬虫的登录态踩坑

最近做了一个内部文档同步工具,目标很简单:把浏览器里有权限访问的语雀知识库定时同步成本地 Markdown,再给后续 Agent 检索、引用和 RAG 使用。

真正卡住的不是目录解析,也不是 Markdown 入库,而是登录态。Playwright 自带 Chromium、Playwright channel: "chrome"、系统 Chrome 独立 Profile 都试过,前两种卡在滑块/风控,第三种又遇到 Profile 锁和会话复用问题。最后稳定下来的路径是:让系统 Chrome 常驻登录,CLI 通过本地 CDP 连接这个已登录页面,在页面上下文里执行 fetch

这篇只记录这条踩坑链路:每种方式怎么做,为什么不顺,最后 CDP 方案的边界是什么。

1) 问题场景

语雀文档不是公开页面,直接请求会跳登录页:

1
2
3
GET https://yuque.example.com/group/book/doc
-> 302 /login?goto=...
-> 200 login page

如果有语雀企业能力或者开放 API Token,最直接的做法当然是走官方 API。但现实里经常会遇到:

  • 没有合适的企业 API 权限。
  • 账号本身可以在浏览器里访问,但脚本没有机器身份。
  • 不希望把 Cookie、Token、密码交给 Agent 或脚本。
  • 还要支持批量目录发现、增量同步、本地索引。

所以当时约束很明确:

1
2
3
4
5
不能读取 Chrome Cookie 明文
不能导出 token / secret
不能模拟拿个人凭据
可以让用户手动登录一个独立浏览器 Profile
同步器只能复用这个已授权浏览器上下文

最终要跑通的是:

1
2
3
4
5
6
7
8
9
doc-search login
-> 打开登录窗口
-> 用户手动登录

doc-search sync --scope <scope>
-> 发现目录
-> 下载 Markdown
-> 写 SQLite
-> 镜像到 data/exported

2) 第一版:Playwright 自带 Chromium

最自然的第一版是 launchPersistentContext

1
2
3
4
5
6
7
8
9
import { chromium } from "playwright";

const context = await chromium.launchPersistentContext("./data/browser-profile", {
headless: false,
viewport: { width: 1440, height: 1000 },
});

const page = await context.newPage();
await page.goto("https://yuque.example.com/group/book/doc");

这里的想法是:

1
2
3
4
5
6
7
8
第一次:
Playwright Chromium 打开登录页
用户手动登录
登录态保存在 data/browser-profile

后续:
Playwright 复用 data/browser-profile
自动访问目录和 Markdown 导出 URL

这个方式工程上最顺:

  • Playwright 原生支持持久化 Profile。
  • 可以统一管理页面、下载、超时、请求。
  • 后续跑 headless 也方便。

但实际登录时卡在语雀/阿里系滑块校验。虽然浏览器是有界面的,不是 headless,但它仍然是 Playwright 管理的 Chromium,自动化特征比较明显。

典型现象:

1
2
3
4
5
打开登录页
输入登录信息
滑块一直过不去
登录态无法写入 profile
后续 sync 调接口返回 401 或登录页

这里要注意一个容易误判的点:不是只有 headless 才会被识别。即使 headless: false,自动化浏览器仍然可能带有一组和普通 Chrome 不一样的行为特征。

3) 第二版:Playwright 启动本机 Chrome

下一步是让 Playwright 不用自带 Chromium,而是用机器上的正式 Chrome:

1
2
browser:
channel: chrome

代码改成:

1
2
3
4
5
const context = await chromium.launchPersistentContext("./data/browser-profile", {
headless: false,
channel: "chrome",
viewport: { width: 1440, height: 1000 },
});

这个方案比自带 Chromium 更接近用户日常浏览器:

  • 使用本机正式 Chrome。
  • 兼容性更接近真实用户。
  • 滑块/SSO 理论上更容易通过。

但它仍然是 Playwright 启动的 Chrome。登录阶段依旧有自动化控制痕迹,滑块不稳定,不能作为可靠的同步入口。

这一步的结论是:换成正式 Chrome 可以降低差异,但不能消除 Playwright 启动链路本身带来的自动化特征。

4) 第三版:系统 Chrome + 独立 Profile

然后换成更接近人工操作的方式:不用 Playwright 打开登录页,而是通过系统命令启动正式 Chrome,并指定一个独立 Profile。

macOS 上大概是这样:

1
2
3
4
5
open -na "Google Chrome" --args \
--user-data-dir=/path/to/company-doc-search/data/browser-profile \
--no-first-run \
--no-default-browser-check \
https://yuque.example.com/group/book/doc

这一步有明显改善:滑块可以通过,用户能正常登录并打开目标文档。

但新的问题出现了:Chrome Profile 是单进程持有的。只要系统 Chrome 窗口还开着,Playwright 再用同一个 user-data-dir 启动就会失败:

1
2
Failed to create a ProcessSingleton for your profile directory.
This usually means that the profile is already in use by another instance of Chromium.

如果让用户登录后关闭 Chrome,再由 Playwright 重新打开同一个 Profile,也不够稳:

  • 登录态可能还没完全落盘。
  • 某些 SSO/风控状态和当前浏览器进程绑定得更紧。
  • Playwright 重新启动 Chrome 时又会带上自动化参数。
  • 有时重新打开后又回到登录页。

所以第三版能解决“登录滑块”,但没有解决“同步器如何稳定复用已登录上下文”。

5) 最后方案:系统 Chrome 常驻 + CDP 连接

稳定方案是把职责拆开:

1
2
3
4
5
6
7
8
系统 Chrome:
负责真实登录、滑块、SSO、会话保持

同步器:
不再抢 profile
不再读取 Cookie
只通过 CDP 连接已打开的 Chrome
在已登录页面上下文里执行 fetch

启动 Chrome 时加一个本地调试端口:

1
2
3
4
5
6
7
open -na "Google Chrome" --args \
--user-data-dir=/path/to/company-doc-search/data/browser-profile \
--remote-debugging-port=9223 \
--remote-allow-origins="*" \
--no-first-run \
--no-default-browser-check \
https://yuque.example.com/group/book/doc

用户登录成功后,保持这个 Chrome 窗口打开。CLI 通过 CDP 发现当前页面:

1
curl http://127.0.0.1:9223/json/list

返回里能看到类似:

1
2
3
4
5
6
7
8
[
{
"type": "page",
"title": "文档标题",
"url": "https://yuque.example.com/group/book/doc",
"webSocketDebuggerUrl": "ws://127.0.0.1:9223/devtools/page/..."
}
]

同步器连接这个 webSocketDebuggerUrl,调用 Runtime.evaluate,让页面自己发请求:

1
2
3
4
5
6
7
8
9
const result = await cdp.send("Runtime.evaluate", {
expression: `
fetch("https://yuque.example.com/api/xxx", {
credentials: "include"
}).then(r => r.text())
`,
awaitPromise: true,
returnByValue: true,
});

关键点在 credentials: "include":请求发生在已登录语雀页面的浏览器上下文里,浏览器会自己带上当前会话。脚本没有读取 Cookie 值,也没有把 Cookie 导出到本地配置。

最终链路变成:

1
2
3
4
5
6
7
8
9
10
11
12
doc-search login
-> 系统 Chrome + 独立 profile + remote debugging port
-> 用户手动登录
-> 保持窗口打开

doc-search sync
-> 访问 127.0.0.1:9223/json/list
-> 找到 yuque 页面 target
-> WebSocket 连接 CDP target
-> Runtime.evaluate(fetch(...))
-> 拿目录 / Markdown
-> 写 SQLite / Markdown 镜像

6) 为什么 CDP 方案能绕开前面的坑

它不是绕过权限,而是把权限边界放回浏览器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
登录、滑块、SSO:
由真实系统 Chrome + 人完成

会话保存:
由 Chrome Profile 管

同步请求:
在已登录页面上下文内发起

脚本权限:
只连接本机 CDP 端口
不读取 Cookie 数据库
不打印 Cookie
不保存 Token

对比前几种方式:

方式 实现方式 优点 问题
Playwright 自带 Chromium launchPersistentContext 工程最顺,API 完整 滑块/风控容易失败
Playwright + channel: "chrome" 用正式 Chrome 但仍由 Playwright 启动 兼容性更接近真实 Chrome 仍有自动化启动特征
系统 Chrome 独立 Profile open -na "Google Chrome" --args --user-data-dir=... 滑块可人工通过 Profile 被 Chrome 占用,Playwright 不能再抢
系统 Chrome + CDP Chrome 常驻,脚本连调试端口 不抢 Profile,不读 Cookie,复用真实登录上下文 需要本地端口和常驻窗口,部署形态要管控

7) 目录发现不要只依赖“我的知识库”

同步语雀组织知识库时还有一个小坑:/api/mine/book_stacks 不一定能列出目标知识库。

普通个人知识库可能在这里:

1
https://www.yuque.com/api/mine/book_stacks

但组织空间里的知识库,当前账号能打开文档,不代表它会出现在“我的知识库栈”接口里。更稳的兜底是从已打开文档页的 window.appData 里取当前 book 和 toc:

1
2
3
4
5
6
7
8
9
10
const appData = await evaluateOnPage(`
(() => {
const appData = window.appData || {};
return {
group: appData.group || null,
book: appData.book || null,
doc: appData.doc || null
};
})()
`);

如果页面里已经有:

1
2
3
4
5
appData.book.id
appData.book.slug
appData.book.name
appData.book.toc
appData.doc.slug

同步器就不必再从“我的知识库”反查目标 book。这个兜底对组织文档特别有用。

8) Markdown 同步的实现骨架

这次同步只做 Markdown,不做 DOM 解析。每篇文档的导出 URL 可以按文档 slug 拼:

1
https://yuque.example.com/group/book/{doc_slug}/markdown?attachment=true&latexcode=false&anchor=false&linebreak=false

同步过程:

1
2
3
4
5
6
7
8
9
book.toc
-> 还原目录树
-> 遍历 DOC 节点
-> 页面上下文 fetch Markdown
-> normalize
-> sha256(content)
-> 写入 generation
-> active_generation 原子切换
-> 写出 Markdown 镜像文件

本地存储不要只靠文件。文件适合人看,但不适合维护同步状态。更稳的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SQLite:
docs
id
source_url
title
active_generation
sync_state
index_quality
export_path

generations
doc_id
generation
content_hash
markdown

filesystem:
data/exported/{group}/{book}/{toc_path...}/{title}_{slug}.md

同步失败时只更新 sync_statelast_error,不动当前 active_generation

1
2
3
4
5
旧版本 G1 可读
-> 开始同步 G2
-> 下载失败 / 解析失败
-> 记录 FAILED
-> active_generation 仍然指向 G1

这样失败不会污染本地可用知识库。

9) CDP 方案的局限

CDP 不是银弹,它更像一个本地授权浏览器的控制面。

它适合:

  • 本地 MVP。
  • 内部工具。
  • 需要复用人工登录态,但不能读取 Cookie 的场景。
  • 目标页面本来就能在浏览器里正常访问。
  • 同步频率不高、可接受浏览器常驻。

它不适合:

  • 无人值守的生产集群。
  • 多用户权限隔离复杂的系统。
  • 需要严格审计每个用户访问边界的服务。
  • 目标站点明确禁止自动化访问的场景。
  • 需要高并发抓取的通用爬虫。

生产化时至少要补这些边界:

  • 独立机器或容器用户。
  • 专用文档账号,最小权限。
  • 只监听 127.0.0.1 的 CDP 端口。
  • 固定 allowlist 域名和知识库范围。
  • 同步日志和失败状态。
  • 敏感内容脱敏策略。
  • 不把 data/browser-profile 提交到仓库。

10) 最后的判断

这次排查下来,我对浏览器自动化登录有一个更明确的判断:

1
2
3
4
5
6
7
8
9
10
如果页面没有强登录风控:
Playwright persistent context 是最顺的。

如果页面有滑块/SSO/风控:
不要和登录页硬刚。
让真实 Chrome 负责登录。

如果还要让脚本稳定同步:
不要关闭后再抢 profile。
让 Chrome 常驻,脚本走 CDP。

这条路径的价值不在“绕过登录”,而在保持清晰的安全边界:人负责授权,浏览器负责会话,脚本只负责在已授权页面里执行有限同步动作。

对内部 Agent 知识库来说,这个边界比“把 Cookie 导出来给脚本”更可维护,也更容易后续替换成专用账号、API Token 或内部代理服务。


从 Playwright 到 CDP:一次语雀文档同步爬虫的登录态踩坑
https://willfordzhan.github.io/2026/05/03/playwright-cdp-yuque-crawler/
作者
詹文杰
发布于
2026年5月4日
许可协议