别用截图驱动:CDP 直连 Chrome,把华为云 DevSpace 的每日任务做成可重跑脚本

举报
yd_245773411 发表于 2026/11/06 07:35:40 2026/11/06
【摘要】 不装 Selenium、不装 Playwright,只用 9222 端口和一个 websocket 客户端,把「人点页面」拆成定位、执行、验证、判定四层。包含 Runtime.evaluate 的 awaitPromise 坑、contenteditable 的正确输入方式、从 iframe 里抠容器 host、用 sendBeacon 钩子核对官方打点,以及为什么「页面变长」不能当作任务完成的判

把一个网页任务自动化,最省事的写法往往最脆:截图找图、坐标点击、固定 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 这类结构耦合;判定尽量走接口,少依赖文案。做到这两点,改版时的修复成本通常只有几行。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。