别用截图驱动:CDP 直连 Chrome,把华为云 DevSpace 的每日任务做成可重跑脚本
把一个网页任务自动化,最省事的写法往往最脆:截图找图、坐标点击、固定 sleep。页面一改版就全废,而且失败时你根本不知道是没点到、点错了,还是点到了但平台没记。这篇讲另一条路:直接接 Chrome DevTools Protocol,用浏览器自己的执行环境做定位与验证,全程零第三方依赖。
一、为什么是 CDP 而不是 Selenium
前提是浏览器里已经有一个登录好的会话——这点很关键,很多平台的登录态藏在 HttpOnly Cookie 和前端内存里,重新起一个干净的浏览器实例等于从头做一次登录,还要过验证码。CDP 的好处是接管已有的那个 Chrome,登录态、扩展、代理全都不用重建。
# 启动时开调试端口(已登录的实例)
chrome --remote-debugging-port=9222
# 看有哪些标签页
curl -s http://127.0.0.1:9222/json | jq -r '.[] | "\(.type) \(.url)"'
# 新开一个标签页(注意是 PUT,不是 GET)
curl -X PUT "http://127.0.0.1:9222/json/new?https://developer.huaweicloud.com/grow"
客户端只需要一个 websocket 连接,加上 Page.navigate、Runtime.evaluate、Input.dispatchKeyEvent 这几个方法。整个封装不到一百行。
二、Runtime.evaluate 的两个必踩坑
第一个坑:异步表达式必须开 awaitPromise。写一个 (async()=>{ const r = await fetch(...); return ... })(),如果不带这个参数,返回的是 Promise 对象本身,序列化后就是一个空对象 {}——看起来像接口返回空,实际是根本没等。
def js(self, expr, await_promise=True):
r = self.send('Runtime.evaluate', {
'expression': expr,
'returnByValue': True,
'awaitPromise': await_promise,
'userGesture': True,
})
return r.get('result', {}).get('value')
第二个坑:userGesture 不开,很多点击是「无效点击」。浏览器会把没有用户手势的 click 当成脚本触发,某些前端库会直接忽略,或者拦在权限检查上。表现就是按钮点了没反应,但控制台也不报错。
三、点击与输入:分两类处理
普通按钮用 DOM 的 click() 就够,但要注意选择器别贪宽。比如页面上有多个「签到」字样,第一次我用 div/span 通配去匹配,结果点中了 9 个容器元素,签到纹丝不动;改成按标签和文案精确匹配 button 才生效:
// 错误:通配会把容器也算进去
[...document.querySelectorAll('button,div,span')].filter(e => /签到/.test(e.innerText))
// 正确:限定标签 + 精确文案 + 可见性
[...document.querySelectorAll('button')].find(e => e.innerText.trim() === '立即签到' && e.offsetParent)
第二类是富文本输入框。现代 AI 对话页普遍用 contenteditable,直接改 innerText 或 value 不会触发框架的输入事件,发送按钮一直是灰的。正确做法是模拟真实输入:
a.send('Input.dispatchMouseEvent', {'type': 'mousePressed', 'x': 250, 'y': 700,
'button': 'left', 'buttons': 1, 'clickCount': 1})
a.send('Input.dispatchMouseEvent', {'type': 'mouseReleased', 'x': 250, 'y': 700,
'button': 'left', 'buttons': 0, 'clickCount': 1})
a.send('Input.insertText', {'text': '你好,请用一句话说明你能帮我做什么'})
for ev in ('keyDown', 'keyUp'):
a.send('Input.dispatchKeyEvent', {'type': ev, 'key': 'Enter', 'code': 'Enter',
'windowsVirtualKeyCode': 13, 'nativeVirtualKeyCode': 13})
insertText 走的是编辑命令通道,等价于输入法上屏,框架能收到 input 事件;再补一对 Enter 的 keyDown/keyUp,发送逻辑才会被真正触发。
四、容器在 iframe 里,host 得自己抠
DevSpace 的体验入口点进去不是跳转,而是在当前页嵌一个 iframe,真正的容器地址藏在 src 里。要操作容器内的页面,就得先把它读出来,再单独开一个标签:
src = d.js("(()=>{const f=document.querySelector('iframe');return f?f.src:'none'})()")
if 'workspace.developer' in src:
host = src.split('?')[0].rstrip('/') # 去掉 query,避免带一次性参数
urllib.request.urlopen(urllib.request.Request(
'http://127.0.0.1:9222/json/new?' + urllib.parse.quote(host + '/aishell', safe=''),
method='PUT'))
这里有个顺序问题:容器是异步创建的,iframe 出现不代表里面已经就绪。实践下来是轮询 src,拿到 host 之后还要再等十几秒才能输入,否则输入框还没挂载,insertText 打进空气里。
五、验证打点:钩住 sendBeacon 看官方到底发了什么
「我点了按钮,任务却没记上」是最难查的一类问题。与其猜参数,不如让页面自己把请求交出来。不少平台用 navigator.sendBeacon 上报埋点,钩一下就能拿到原始 payload:
const orig = navigator.sendBeacon.bind(navigator);
window.__beacons = [];
navigator.sendBeacon = function(url, data) {
try { window.__beacons.push({url, body: typeof data === 'string' ? data : '[blob]'}); } catch (e) {}
return orig(url, data);
};
拿到之后跟自己手发的请求体逐字段比对。实测结论是:手发的内容和官方完全一致,接口也回了 {"success":true},说明打点从来不是问题所在,问题在后面的动作是否真的产生。这一步省掉了很多无效试错——如果没验证,很容易一直在改 payload。
六、判定:页面文本变长不等于成功
这是最容易自欺的一处。AI 对话页在等待回复时会不断追加「思考中」「正在生成」这类状态文本,正文长度一直在涨,用长度做判据会立刻误报成功。真正可靠的判据是内容特征:
now = a.js('document.body.innerText') or ''
ok = ('Thought' in now) and (len(now) - len(before) > 200)
更稳的是直接问接口。成长中心的任务状态有独立接口,返回的 finish 字段才是平台自己的记账结果:
const r = await fetch('/api/growth/tasks', {credentials: 'include'});
const j = await r.json();
// j.tasks[].today_status: done / todo / unknown
页面说完成、接口说没完成,以接口为准;两边都完成但积分没涨,那是发奖批次的问题,不是动作没做。
七、幂等:先读状态,再决定动不动手
脚本要能天天跑,第一件事是能安全地重复跑。开头先查任务状态,已经是 done 就直接退出,只有显式传 --force 才重来。容器这类有配额的资源尤其要这样:不开新的、不重复占用,就不会因为「昨天那个还没释放」把今天的额度吃掉。
st, seg = read_state()
if st == 'done' and '--force' not in sys.argv:
print('RESULT: 已完成,跳过')
return 0
配套的是超时与窗口。容器创建、页面加载、模型回复都可能是分钟级,脚本里每个阶段都要有自己的上限,超了就报出来,而不是无限等——无限等的脚本在定时任务里等于把下一次执行也堵死。
八、结论
CDP 这条路的价值不在于「能点按钮」,而在于每一步都可观测:定位错了能看到匹配到几个元素,输入没生效能读到输入框内容,打点对不对能抓到原始 payload,任务成没成能问接口。把这四层拆开,脚本才从「一次性的演示」变成「可以天天跑、坏了能查」的东西。
代价是要接受页面会变。所以选择器尽量贴着语义(标签 + 文案 + 可见性),少用 nth-child 这类结构耦合;判定尽量走接口,少依赖文案。做到这两点,改版时的修复成本通常只有几行。
- 点赞
- 收藏
- 关注作者
评论(0)