通道怎么选:先把"是什么"和"从哪看"拆开
0次浏览0条评论0个赞
飞书回调地址填对了、事件订阅加对了、权限也开全了——3 项配置全对,机器人一句话不回,后台只提示:“返回数据不是合法的 JSON 格式”。
大多数人看到这句话会先去查自己的 JSON 序列化。但真相是:飞书的 POST 请求根本没到我的代码——它其实在更前面就被拦下、重定向到登录页,收到了一段 HTML。“不是合法 JSON”这句话是真的,但它说的不是我。
一、案例一:症状与真因隔着三层
通道问题里最贵的一类错误,是把”接通了”当成”能用了”。 连接建立、HTTP 200、退出码 0,每一个都可能在你真正要的东西没拿到时依然成立。先看两次现场,再把”约束在哪里、看的是哪一份会话、什么才算成功”拆成三个判断。
先把现场摆出来。目标很简单:群里 @ 机器人,它能回话。
按官方文档的标准做法,用的是”将事件发送至开发者服务器”——也就是回调模式:飞书收到消息后,POST 到你提供的公网地址。
配置逐项检查过,全对。然后机器人沉默。
后台给的那句提示值得单独看:“返回数据不是合法的 JSON 格式”。这句话非常具体,也非常误导:
| 层 | 实际发生的事 |
|---|---|
| 用户看到 | 机器人不回 |
| 飞书后台看到 | 回调响应不是合法 JSON |
| 网络上实际发生 | 请求被身份校验层拦下,302 到登录页 |
| 我的代码 | 从未收到这个请求 |
三层之外的真因,和被读到的症状之间,没有一层是”代码写错了”。
问题出在一个组合上:服务放在身份校验层之后,而那一层默认拦住该域名的全部路径。于是飞书的回调请求被当成”一个没登录的用户来访问”,被重定向到登录页;飞书拿到那段 HTML,解析失败,于是报”不是合法 JSON”。它报得没错——它确实没拿到 JSON。
二、案例一续:换一条没有这个约束的通道
知道真因之后,有两条路可走:
- 在身份校验层里为回调路径开一条白名单;
- 换一条不需要公网入口的通道。
我选了第二条,理由是它同时消掉了一整类问题:改用飞书的长连接模式——由我这一侧的进程主动连到飞书的 WebSocket 入口,事件通过这条连接推下来。
飞书群 @机器人
└─ 飞书 WS 入口 ──(我主动连出去的长连接)──> 我的 bridge 进程
└─> agent(headless)
└─> 回复发回群 + 对话落盘
它换来的东西比”能用了”更多:
- 零公网暴露面:不需要把任何回调端点暴露出去,身份校验层与整件事无关;
- 配置面缩小:不再需要处理回调地址、Verification Token 握手、反向代理路由这一串;
- 一句诊断:连接成功时日志里能看到
connected to wss://…/ws/v2——这是一个直接对应”通道通了”的证据,而不是间接推断。
这里有一条我认为可以带走的判断:
当一条通道的约束来自”你不得不暴露一个入口”时,最优解常常是换一条根本不需要入口的通道,而不是把那个入口配置得更精细。
代价也要说清楚:长连接模式要求你的进程常驻(进程死了就没有事件),而回调模式是”你来我才在”。这是一个”运维负担换暴露面”的交易。
顺带撞上的插件运行时约束
把 bridge 的能力做成一个界面插件时,又撞到三条分开看都对、合起来才致命的约束:
- 插件的客户端半边不能直接用
fetch——网络访问属于宿主半边; - 客户端半边也不能用
setInterval——必须声明依赖并走注入的定时器服务; - 半边的私有 RPC 是包内可见的——只能调同一个包内配对的另一半。
所以”让面板去请求一个接口”这件事,在这里必须改写成:面板 → 私有 RPC → 宿主半边 → 真正发请求。这是运行时的边界,跟风格无关——边界在哪里,代码的形状就得跟到哪里。
三、案例二:两个正交维度
第二个案例换到浏览器,它的问题更”概念性”。
当时有一句反复出现的争论:“我操作的浏览器是无头还是有头?“和”我看到的画面从哪来?“——这两句话被当成同一个问题在谈,于是永远谈不拢。
因为它们是两件互相独立的事。
| 维度 | 取值 | 由谁决定 |
|---|---|---|
| A. 浏览器形态 | 无头 / 有头(真实窗口) | 执行方按任务需要决定 |
| B. 显示通道 | ① 右栏真网页 ② 右栏同会话镜像 ③ 桌面原生窗口 | 观看方从哪看 |
关键结论是这一句:只有”桌面原生窗口”这一条通道和 A 有关。 无头状态下,镜像通道照样有实时画面——因为它传输的是 CDP 帧,和有没有窗口无关。
拆开之后,“装个 Xvfb 就能在侧栏看同会话”这种误解自然消解:Xvfb 改变的是窗口画在哪块屏,而侧栏能不能显示同会话,取决于侧栏有没有镜像实现。两件事互不影响。
四条通道各自是什么
| 通道 | 显示的是谁 | 同一会话? | 能否交互 |
|---|---|---|---|
| ① 右栏真网页(iframe) | 你自己浏览器加载的同一网址 | ✗ 独立一份 cookie | 你能编辑,但改的是你那份 |
| ② 右栏同会话镜像 | 执行方那个浏览器的实时画面 | ✓ | 能点、能滚、能打字(事件转发) |
| ③ 桌面原生窗口 | 执行方那个浏览器本身 | ✓ | 能点,但会和执行方抢焦点 |
这里有一条互斥性质,我认为是最有用的一句:
“真网页可编辑”和”同会话”这两个性质互斥。
iframe 方案 = 真 DOM + 独立会话;镜像方案 = 同会话 + 画面(要靠事件转发才能操作)。所以你不可能选到一个”既是我那个会话、又是真 DOM”的侧栏通道——这是两种方案的代价结构不同,跟实现好坏无关。
选哪个,取决于你要的是”我能精细编辑”还是”我能看到它正在做什么”。前者要真 DOM,后者要同会话。
四、贯穿线:接通是一个会伪造成功的信号
把两个案例放在一起,能看到同一个形状:
“接通”发生在链路的某一层,而”能用”发生在链路的最后一层。这两层之间每多一层,接通这个信号就越容易伪造成功。
- 飞书案例里:TCP 连上了、请求发出去了、对方也响应了——但响应是 302,而”有响应”被当成了”回调通了”;
- 浏览器案例里:流端点返回 HTTP 200、观察者计数正常、进程活着——但一帧都不出。
所以我把判据固定成一句话:
判据必须定义在最终产物上,而不是链路中间的任何一层。
对飞书,“最终产物”是机器人真的在群里回了一句话;对镜像通道,“最终产物”是托管页面上回读到了我在镜像里敲进去的那个字符串。这两个判据看的是”我要的东西出现了”,而不是”连接成功”。
反过来说,凡是只在最后一层成立的判据,都不该被中间层的成功信号替代——这条在下面一节会以最尖锐的形式出现。
五、三种”看起来通了”
5.1 302 被当成”响应正常”
第一节那个案例。判据缺失的形式是:把”有响应”当成了”响应正确”。修法是把判据下移到内容层(真拿到 JSON 事件),而不是状态层。
5.2 HTTP 200,零帧
实现镜像通道时撞上的潜伏 bug:会话获取函数里写了这样一个判断——
if (cdp.page && !cdp.page.isClosed() && cdp.page.isConnected())
isConnected() 是 Browser / BrowserContext 的方法,Page 上没有它 [1][2][3]。于是只要存在活跃会话,这个判断就抛 TypeError。
它为什么能潜伏很久?因为唯一的调用方在会话有效时会短路,根本走不到这一行。直到新增了镜像功能成为第二个调用方,它才被触发——而症状极其难定位:
HTTP 200 ✓ 观察者计数正常 ✓ 进程存活 ✓ 帧数:0
每一层都在说”通”,最后一层的产物是空的。 这正是第四节那条判据的反面教材。
5.3 一个连”成功”都不肯返回的等待
同一个函数里还有第二处:会话获取的守卫一旦不 settle,调用方会永久挂起——没有超时、没有错误、没有日志。修法是把它包进既有的超时封装,并在写响应头时主动 flush,否则首帧到达前客户端看到的是 0 字节。
这一处的教训与前一节不同:前一处是”产物为空但信号正常”,这一处是”连信号都没有”。两者的共同点仍然是把等待当成了过程——对一个不保证返回的调用,超时属于接口的一部分,不是保险。
六、决策规则
把两个案例的结论固化成几条可以直接用的规则。
形态(无头 / 有头)怎么选:
- 默认无头:不抢屏、不占桌面;
- 需要过风控时切有头:实测无头浏览器在某个风控挑战上连续 403、挑战框点不动;换到虚拟屏上的有头浏览器后,同一个地址直接渲染出完整登录页;
- 不想占桌面又要过风控 → Xvfb:虚拟显示上跑有头浏览器,效果等于”有头但不占屏”;
- 切换后要用命令复核形态,而不是相信”我改了配置”(光看配置不算,要看进程实际带的参数)。
通道(从哪看)怎么选:
- 要精细编辑 → 真网页通道(代价:独立会话);
- 要看执行过程 → 同会话镜像(代价:只读画面 + 事件转发,且无声音);
- 遇到禁止被 iframe 内嵌的站点 → 镜像通道是唯一能看到内容的通道(实测某些域名直接返回
X-Frame-Options: DENY/SAMEORIGIN)。
一个共享会话时的注意事项: 通过调试端口复用一个正在运行的浏览器时,不要对它调用关闭方法——那会关掉对方正在用的页面。共享连接的”清理”语义和独占连接完全不同。
七、局限与边界
- 实测结论都绑定在特定版本与时点(2026-09-12 前后的两组环境)。风控与内嵌策略都属于会变的外部约束,通道结论要按当时实测复核。
- 镜像通道的定位是”观看与操控”,不是”编辑界面”。 它的输入靠事件转发,弱于真 DOM——用它做精细文本编辑是错误用法。
- 长连接模式要求进程常驻。 它换掉了公网暴露面,但引入了”进程死了就收不到事件”的新失败模式,需要单独的健康检查。
- 第五节那两处缺陷的修法只解决了已观察到的形态。 我给出的是”这一处为什么会静默”,而不是”这类代码不会再有别的问题”——把等待当过程、把中间层当终点,这两种错误在别处一定还会以别的形式出现。
- “两个正交维度”是这一套运行的抽象。 换成别的浏览器托管方案时,维度是否仍然正交需要重新验证——不能直接搬运。
结论:能接通不等于能用,判据要落在「我要的东西出现了」
回到开头那句”返回数据不是合法的 JSON 格式”。
它说得完全正确,只是它描述的不是我以为的那件事。而这类偏差之所以危险,是因为每一层的信号都自洽:飞书收到了响应,网络层完成了重定向,我的服务健康地运行着——没有任何一层在报错。
所以我现在处理通道问题的顺序是固定的:
先拆维度(约束在哪里、会话是哪一份、从哪看),再把判据钉在最终产物上(我能看到/拿到我要的东西),最后才去调参数。
顺序反了会怎样?前面两节已经演示过:你会在一层自洽的信号栈里,去一个错误的地方找答案。换句话说,通道不是”接上就行”的东西——它的正确性只在你真正要的那个产物上体现,而不在链路的任何中间层。
参考文献
[1] 本机 Playwright 类型定义 playwright-core/types/types.d.ts:isConnected(): boolean 声明在 Browser 接口内(该接口自第 9543 行起,方法上方的注释为 Indicates that the browser is connected.),而 Page 接口(自第 73 行起)中没有该方法。第五节那条判据的一手来源,可在任何装有 Playwright 的机器上当场复核。
[2] Playwright · Page 类 API 文档:https://playwright.dev/docs/api/class-page(2026-10-11 实测可达)
[3] Chrome DevTools Protocol · Page domain(含 Page.startScreencast / Page.screencastFrame / Input.dispatchMouseEvent):https://chromedevtools.github.io/devtools-protocol/tot/Page/(2026-10-11 实测可达)
关于实测证据的核验说明:飞书回调被身份校验层拦下的现象、以及镜像通道”HTTP 200 但零帧”的现场,都发生在我的私有环境里,原始日志不在公开位置。可替代的核验路径有三条,且都不依赖我的环境:① Page.isConnected 不存在这件事可以直接查本机类型定义 [1];② Page.startScreencast 与输入事件的语义在 CDP 文档里是可查的公开协议 [3];③ “未登录时身份校验层返回 302 而不是错误码”这一行为,用 curl -I 对任何套了该类型的地址看一眼响应头就能复现。





评论
填个昵称就能发,不需要注册或登录