做多平台自动发布时,光看发布工具的回执是不够的------发布工具自己也会把「上传成功」误判成「发布成功」。所以要补一层平台侧核验:起一个 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 秒出结果。
六、复盘:这个坑教会我的三件事
-
CDP 响应是双层的,别只看一层。 凡是
Runtime.evaluate/Runtime.callFunctionOn这类方法,取值都要response.result.result.value,少一层就是None。这个模式在verify_deep.py里早就写对了,我另起脚本时抄漏了一层------复用已有正确实现比重新写更安全。 -
ws 连接是稀缺资源,不是无限管线。 每次
evaluate都是一次 recv 等待,重试次数 × 超时时长要克制。诊断信息可以写日志,但不要靠「多发几个表达式」来凑。 -
「取值为 None」不等于「页面有问题」。 这次的症状千像万像,根因却只是取值层级错。遇到 CDP 取不到值,先怀疑自己的解析代码,再怀疑页面------多数时候是前者。
最后一点延伸:平台侧核验之所以值得做,正是因为发布工具自身的回执不可全信。我同一次发布里就出现过 pushData 记着 success、平台后台却根本没有这条内容的情况(头条假阳性)。回执说成功,和平台真的有这条,是两件事------而 CDP 抓页面文本,是验证「平台真的有」最直接的一招。
(本文调试代码均来自 verify_juejin.py 实际运行与修复过程,未做简化删改。)