用 CDP 驱动网页自动化绕不开的 8 个坑,每个都附可直接抄的解法
为什么要手写 CDP
用 Playwright / Selenium 当然更省事,但有两种场景绕不开裸 CDP:一是不想装浏览器驱动、只想对已经在运行的、带你登录态的 Chrome 做操作;二是页面用了重度前端框架加富文本编辑器,选择器一改就崩,只能靠"看页面实际发生了什么"来写脚本。
我最近连续几天用裸 CDP 驱动华为云开发者社区的签到、发文、提建议、答题,踩了不少坑。下面 8 个,每个都给出症状和解法,代码是 Python + websocket-client 直连 --remote-debugging-port。
import json, websocket
ws = websocket.create_connection(tab["webSocketDebuggerUrl"], suppress_origin=True)
seq = 0
def send(method, **params):
global seq; seq += 1
ws.send(json.dumps({"id": seq, "method": method, "params": params}))
while True:
msg = json.loads(ws.recv())
if msg.get("id") == seq:
return msg.get("result", {})
def js(expr):
r = send("Runtime.evaluate", expression=expr, awaitPromise=True, returnByValue=True)
return r.get("result", {}).get("value")
坑 1:JS click 不触发,真实鼠标才触发
document.querySelector('button').click() 派发的是不可信事件(isTrusted: false)。很多组件库的按钮监听的是 pointerdown/mousedown/mouseup 的完整序列,或者直接判断 event.isTrusted,于是 click() 毫无反应,页面也不报错——你会以为是选择器写错了。
解法是用 CDP 派发真实输入事件,先拿到元素坐标:
box = js("""(()=>{const b=document.querySelector('#submit-btn');
const r=b.getBoundingClientRect();
return JSON.stringify({x:Math.round(r.x+r.width/2), y:Math.round(r.y+r.height/2)})})()""")
x, y = json.loads(box)["x"], json.loads(box)["y"]
send("Input.dispatchMouseEvent", type="mouseMoved", x=x, y=y)
send("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button="left", clickCount=1)
send("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button="left", clickCount=1)
顺序别省:先 mouseMoved 让 hover 态生效,否则某些按钮在 hover 前是 disabled。
坑 2:视口太窄,两个控件叠在一起
同一个页面在 1600px 宽时正常,在 988px 宽时"关联产品"面板直接压在提交按钮上。JS click 仍然"成功",因为坐标算出来的中心点落在遮挡层上,事件被它吃掉。
两条解法一起用:
send("Emulation.setDeviceMetricsOverride", width=1600, height=1000,
deviceScaleFactor=1, mobile=False)
再用 elementFromPoint 自检——这是最便宜的断言:
const b = document.querySelector('#submit-btn');
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width/2, r.y + r.height/2);
hit === b || b.contains(hit) // 命中自身才继续点
如果返回的是别的元素,直接说明"这个宽度下点不到",别硬点。
坑 3:校验读的是隐藏字段,不是你能看见的那个框
标签、分类、地区这类控件,页面渲染的那层是给用户看的,提交时读的往往是隐藏 input。我遇到的标签控件肉眼可见一个输入框,但提交函数里读的是另一处文本节点:
var tagList = $('#labelTxt').text().split(',');
if (!tagList[0]) return false; // 静默失败,页面没有任何提示
在这种设计下,往可见输入框里打字永远不会生效。要么直接写隐藏字段,要么改成写组件内部状态。判断办法很简单:提交前把所有 input[type=hidden] 和隐藏容器的值打印一遍,看哪个字段和提交逻辑对得上。
坑 4:协议勾选是静默 return 的开关
提建议、发文、报名这类表单,几乎都有"我已阅读并同意"的勾选。提交函数的第一行往往是:
if (!$('#agreementBox').hasClass('checked')) return false;
而那个 .checked class 不是点一下就会有的——有的库在 click 之后异步加,有的要监听 change。表现出来就是"点提交毫无反应,控制台也不报错"。解法:先定位真实 checkbox,用真实鼠标点,然后回读 class 或 checked 属性确认状态,再点提交。
坑 5:session cookie 一关浏览器就没了
这条最容易被忽略,因为它不是代码问题。Chrome 的持久 profile(--user-data-dir)只保住带过期时间的 cookie;纯 session cookie(没有 Expires)在进程结束时被清掉,无论 profile 目录多完整。
于是脚本"昨天还好用,今天全是未登录"。我试过把 Preferences 里 session.restore_on_startup 设成 1(继续浏览上次打开的网页),再优雅关闭 Chrome,华为云的登录态照样失效——不要在这上面浪费时间。
正确的做法是接受它:脚本里检测到未登录就停下来并提示人去登录,不要试图自动化登录流程(输密码、过验证码这种事既拿不到也不该做)。
坑 6:多标签页必须按 URL/title 挑,不能按顺序
/json/list 返回的顺序不保证,点"开始实验"会新开标签,旧的标签还在。如果你的脚本固定取 pages[0],下一次就会在错误的页面上执行,症状是"脚本明明执行了但页面没变化"。
tabs = [t for t in json.load(urlopen(BASE + "/json/list")) if t["type"] == "page"]
tab = next(t for t in tabs if "developer.huaweicloud.com/grow" in t["url"])
按 URL 子串匹配,匹配不到就报错退出,别 fallback 到"随便取一个"。
坑 7:dispatchKeyEvent 逐字符输入会重复字母
想模拟真人打字,很多人写 for ch in text: dispatchKeyEvent(type="char", text=ch),结果 KooLabs 变成 KKooooLLaabbss——因为 char 事件和输入法的组合事件会叠加。
两个可靠选择:
Input.insertText:一次性插入,等效粘贴,对绝大多数输入框和 CodeMirror 都有效;- 直接写值 + 派发
input/change:
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(input, '要填的内容');
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
Object.getOwnPropertyDescriptor(...).set.call 是关键——Vue/React 会劫持 input.value 的 setter,直接赋值不触发响应式更新。
坑 8:富文本编辑器各有各的玩法
同一天我操作了三个编辑器,三套写法:
| 编辑器 | 怎么填 |
|---|---|
| TinyMCE | tinymce.get('editor').setContent(html) 然后 save();实例名可能是 editor 也可能是 ueditor,先 Object.keys(tinymce.editors) |
| CodeMirror 6 | 没有 textarea,只有一个 contenteditable 的 .cm-content,先 focus() 再 Input.insertText |
| 自定义 tagator | 下拉列表常是 display:none 内联死的,普通 click 不展开;.tagator_element 触发 focus 或用 jQuery 打开,再对目标项派发 mouseover/mousedown/mouseup/click |
TinyMCE 那条还藏着个细节:Markdown 必须先转 HTML 再 setContent,否则提交上去是纯文本堆在一起。
收尾:失败时读页面,不要猜
脚本挂了我最常做的两件事:document.body.innerText 整段打印,和把提交按钮的 outerHTML 打出来看 disabled。页面上的 Toast 提示("提交成功""已答过此报告")几乎都在 innerText 里,比读接口返回快得多。
如果一定要读接口,用页面自己的登录态最省事:在页面上下文里 fetch(url, {credentials:'include'}),比自己拼 header 靠谱,也不会因为漏了某个签名参数而返回空数组。
这些坑没有一个是"CDP 难用"造成的,基本都是前端框架的隐式约定。写脚本前先花两分钟在控制台里把控件状态摸清,比写完再 debug 快得多。
- 点赞
- 收藏
- 关注作者
评论(0)