puppeteer 调试实录:CDP 双层响应让取值永远 None

做多平台自动发布时,光看发布工具的回执是不够的------发布工具自己也会把「上传成功」误判成「发布成功」。所以要补一层平台侧核验:起一个 headless Chrome,用 CDP 把登录态 cookie 注进去,导航到创作者后台,把页面文本抓出来搜标题。

这条链路本身不复杂,但我在取页面文本时踩了一个卡了 5 次的坑:页面明明加载成功了,innerText 也确实在,但 Runtime.evaluate 返回的值永远是 None。本文把根因和两次修复完整记下来,省得下次再栽。

一、现象:页面活了,值却是 None

我用 websocket 直连 CDP,注入 cookie 后导航到掘金「已发布」页面,然后读 document.body.innerText。代码大致是:

python 复制代码
def send(method, params=None):
    mid[0] += 1
    ws.send(json.dumps({"id": mid[0], "method": method, "params": params or {}}))
    while True:
        msg = json.loads(ws.recv())
        if msg.get("id") == mid[0]:
            return msg

text = send("Runtime.evaluate",
            {"expression": "document.body ? document.body.innerText : ''",
             "returnByValue": True})
print(text)   # 期望拿到一大段文本,结果反复是 None

诡异的地方在于:我加了诊断输出,发现 document.title 能正确返回 "文章管理 - 创作者中心 - 掘金"location.href 也确实是目标 URL------页面 100% 加载完成了 。但 innerText 就是取不到,整页判定只能报「内容为空」。

二、根因:被双层包裹的 CDP 响应

问题不在页面,在我的取值逻辑。CDP 的 Runtime.evaluate 响应是双层结构

json 复制代码
{
  "id": 7,
  "result": {
    "result": {
      "type": "string",
      "value": "创作者中心\n写文章\n首页\n..."
    }
  }
}

注意这里有两个 result:外层 result 是「这次方法调用的结果」,内层的 result 才是 Runtime.evaluate 真正求值得到的那个 RemoteObject。真正的文本藏在第二层value 里。

我当时只读了外层:

python 复制代码
# ❌ 错误:只读一层,外层 result 里根本没有 value 字段
r = send("Runtime.evaluate", {...})
outer = r.get("result") or {}
val = outer.get("value")        # 永远是 None,因为 value 在更里面

于是无论页面多正常,取到的值永远是 None。这是个极具迷惑性的 bug------症状看起来像「页面没渲染」「被踢登录」「上下文销毁」,但其实是单纯的取值层级错了。

三、第一处修复:往里读两层

修正后的取值,必须穿过两层 result

python 复制代码
def eval_retry(expr, tries=6, gap=4):
    for i in range(tries):
        r = send("Runtime.evaluate",
                 {"expression": expr, "returnByValue": True})
        outer = r.get("result") or {}
        # 导航后执行上下文可能被销毁,会返回 exceptionDetails 而非 value
        if outer.get("exceptionDetails"):
            time.sleep(gap)
            continue
        inner = outer.get("result") or {}   # ← 第二层才是真正的求值结果
        if "value" in inner:
            return inner["value"]
        time.sleep(gap)
    return None

这里还有个伴生坑:Page.navigate 之后,原来的 JS 执行上下文会被销毁,紧接着的 Runtime.evaluate 可能返回 exceptionDetails: "Execution context was destroyed"。这类响应同样没有 value,必须重试,而不是当成普通失败跳过。所以 eval_retry 里对 exceptionDetails 单独判并处理重试,是必须的。

四、第二个坑:ws 被自己的重试拖死

修完第一处,我以为稳了,结果遇到第二个问题:WebSocketConnectionClosedException: socket is already closed

原因很直白:诊断阶段我连发了 4 个表达式(location.href / readyState / title / innerText),每个又各重试 4 次,每次 ws.recv() 的超时设成 40 秒。算下来单次核验要在 ws 上挂很久,Chrome 那头等不及,把连接关了,后面所有 evaluate 全部失败。

这其实是个资源使用习惯问题------CDP over WebSocket 不是 HTTP,连接是有生命周期的,不能把它当「发多少都行」的管线来用。

五、第二处修复:拿到即判定,不再多发请求

把诊断循环彻底砍掉:导航后只发一个 innerText 求值(带滚动触发懒加载),拿到文本立刻判定,不再发任何多余的 CDP 请求。

python 复制代码
# 导航后等渲染
time.sleep(WAIT_RENDER)
# 只滚动一次触发懒加载
send("Runtime.evaluate",
     {"expression": "window.scrollTo(0, document.body.scrollHeight); 1",
      "returnByValue": True})
time.sleep(3)

text = eval_retry("document.body ? document.body.innerText : ''",
                  tries=6, gap=4) or ""
if not text:
    return report("VERIFY_BLOCKED", reason="innerText 为空,页面未取到内容")

found = SEARCH_TERM in text     # 拿到即判定,到此为止

同样的核验,修复前卡了 5 次、跑了 5 分多钟;修复后一次通过,49 秒出结果。

六、复盘:这个坑教会我的三件事

  1. CDP 响应是双层的,别只看一层。 凡是 Runtime.evaluate / Runtime.callFunctionOn 这类方法,取值都要 response.result.result.value,少一层就是 None。这个模式在 verify_deep.py 里早就写对了,我另起脚本时抄漏了一层------复用已有正确实现比重新写更安全。

  2. ws 连接是稀缺资源,不是无限管线。 每次 evaluate 都是一次 recv 等待,重试次数 × 超时时长要克制。诊断信息可以写日志,但不要靠「多发几个表达式」来凑。

  3. 「取值为 None」不等于「页面有问题」。 这次的症状千像万像,根因却只是取值层级错。遇到 CDP 取不到值,先怀疑自己的解析代码,再怀疑页面------多数时候是前者。

最后一点延伸:平台侧核验之所以值得做,正是因为发布工具自身的回执不可全信。我同一次发布里就出现过 pushData 记着 success、平台后台却根本没有这条内容的情况(头条假阳性)。回执说成功,和平台真的有这条,是两件事------而 CDP 抓页面文本,是验证「平台真的有」最直接的一招。

(本文调试代码均来自 verify_juejin.py 实际运行与修复过程,未做简化删改。)

相关推荐
咖啡无伴侣13 分钟前
2. 从零搭建企业级 Monorepo 工程化模板:ESLint 10 (基础骨架)+ Prettier 配置与避坑指南
前端·架构
光影少年17 分钟前
react navite实现全局弹窗、Toast 组件
前端·react native·react.js
WebInfra21 分钟前
Rslib 1.0 正式发布:面向多场景的 JavaScript 库开发工具
前端·javascript·github
Tongsr1 小时前
别只混淆代码:用 Kaleido 加固整个 Android Release AAB
前端·算法·github
郭邯1 小时前
从零到一:我用 AI 写了一个贷款计算器,顺便把等额本息公式彻底搞懂了
前端
汉堡大王95271 小时前
面试官:讲讲归并排序 —— 为什么它能在面试里反复出现?
前端·javascript·面试
kyriewen1 小时前
别再只给人写页面了:AI 已经开始自己点你的按钮、填你的表单
前端·javascript·人工智能
天天喝旺仔1 小时前
Vue 3 组合式 API:从 Options 迁移到 script setup
前端·javascript·vue.js
知了清语1 小时前
以测试为盾,重构为矛——老旧前端系统迭代中的“测试先行”实践总结
前端