资讯动态

用CDP协议让智能体像操作浏览器一样操控Electron桌面应用

发布时间:2026/10/22 19:01:05 来源:尧图企业网站定制
我最早做桌面应用自动化的时候犯过一个很蠢的错误花了两周去调屏幕坐标点击最后发现Electron应用的界面布局一换皮肤所有坐标全部失效整套脚本直接报废。后来真正把Electron应用当作一个Chromium浏览器来处理用CDP协议Chrome DevTools Protocol直接在协议层面控制它才发现这是让Agent智能体操作Electron桌面应用最高效的路径。Electron本身就是Chromium内核页面上的每一个DOM节点、每一次按钮点击、每一次表单输入都暴露在标准化的协议里Agent拿到的不是“屏幕上的像素坐标”而是“语义化的界面状态”。这篇文章写给两类人一类是做自动化测试、需要让脚本或AI智能体去操作各种Electron桌面工具的开发者另一类是正在研究Agent落地想给智能体接上“能操作电脑上的桌面软件”这个能力的人。我会从为什么选CDP、怎么打开Electron的调试端口、如何手写一个最小的CDP客户端到坐标换算、可信事件模拟、完整Demo链路、踩坑记录一起梳理清楚。1. 为什么桌面自动化最终要落到协议层而不是坐标层1.1 坐标脚本、控件树与图像识别都是怎么失效的大家最容易想到的方案是坐标脚本先截个图用图像工具量出按钮的像素位置然后调用系统接口去模拟鼠标点击。这个方案听起来简单实际上非常脆弱。Electron窗口可以拖拽移动Windows的DPI缩放会让坐标整体偏移页面内如果有列表滚动按钮位置瞬间就变了。最致命的是坐标脚本没有任何“确认机制”它不知道按钮是否真的被点中、页面是否真的响应了。我曾经维护过一套基于模板匹配的图像识别脚本每次产品换主题色样例图就要重新截一遍维护成本高到离谱。第二个方案是借助操作系统层面的UI自动化框架通过窗口句柄和控件树来识别元素。这个方案对原生Win32控件有效但Electron应用的内容区域大部分是渲染出来的网页很多按钮不是系统控件控件树里拿到的是一整块空白或WebView节点语义信息几乎为零。也就是说你能知道窗口在哪但窗口里面是什么操作系统并不清楚。第三个方案是纯图像识别加目标检测模型这个适合做“所见即所得”的验证但作为自动化操作的底层依赖并不可靠。识别慢、误识别率高而且识别出来的依然只是坐标没有得到界面背后的语义信息。1.2 Electron本身就是ChromiumCDP是一把现成的钥匙Electron Chromium Node.js 系统级API。任何Electron应用在本质上就是一个浏览器的壳子里面跑的是标准的Web页面。既然它是Chromium那么适用于Chrome的那套调试协议也就是CDP天然就能适用于Electron。CDP是Chrome DevTools Protocol的缩写它通过WebSocket提供了一整套与Chromium内核通信的接口能读取DOM、执行JavaScript、派发鼠标键盘事件、拦截网络请求、截屏等等。关键在于CDP操作的是“元素”而不是“像素”。Agent可以先用Runtime.evaluate在页面里执行一段JavaScript读取页面上所有按钮的文本和位置然后决定点击哪个点击后还能再次读取DOM验证操作是否生效。这套链路天然就适合智能体逻辑。举个例子传统坐标方案里“点击登录按钮”是一堆写死在脚本里的x、y数字而在CDP方案里Agent看到的是一个按钮元素有文本、有坐标、有可点击范围决策和执行都在语义层完成。Electron应用不需要做任何改造只要你把调试端口打开就等于给Agent留了一扇门。下面我把从开门到进屋再到干活的全过程拆开讲。2. 把Electron的调试端口安全地交到Agent手里2.1 版本更新后的连接限制和固定端口方法早期版本的Electron直接用命令行参数--remote-debugging-port9222就能打开调试端口。但Electron 21之后安全策略收紧了很多。首先是端口问题如果你依然通过命令行参数传入remote-debugging-port新版会把端口改成一个随机值而且只允许本机回环地址访问。你可以通过启动日志找到实际的调试地址但这对自动化脚本来说太不稳定了。其次新版本对连接的HTTP Header做了校验要求Host必须是localhost或127.0.0.1否则直接拒绝连接。如果你想固定端口最可靠的方式是在应用的主进程代码里用app.commandLine.appendSwitch在启动早期注册调试开关。也就是在app的ready事件触发之前在main.js的最上方加上const debugPort process.env.AGENT_DEBUG_PORT || 9333; if (process.env.AGENT_ENABLED 1) { app.commandLine.appendSwitch(remote-debugging-port, debugPort); app.commandLine.appendSwitch(remote-debugging-address, 127.0.0.1); }注意app.commandLine.appendSwitch必须在app ready之前、最好在文件顶部就执行Electron启动流程里很多命令行开关只会在初期读取一次。我之前就是把这行代码写在某个初始化函数里结果端口始终没打开排查了半天才发现是调用时机问题。如果你不能改应用源码那么建议从命令行显式启动Electron在部分平台上依然接受--remote-debugging-port参数但你需要从应用启动后的stderr日志里抓取实际生成的调试端口。日志里会打印一行类似DevTools listening on ws://127.0.0.1:某端口/某UUID的内容你的Agent启动脚本可以监听应用日志从中正则匹配出WebSocket地址。这个方式比硬编码端口更费事但胜在完全不需要动应用代码。2.2 怎么拿到目标窗口的WebSocket调试地址端口打开后CDP的HTTP接口会自动启动其中两个端点最常用。/json/version返回浏览器内核版本信息/json/list返回当前所有可调试目标Target也就是每个页面、每个WebView。Agent连上来的第一步通常就是轮询/json/list找到自己想要控制的那个页面。一个Electron程序里可能有多个BrowserWindow每个窗口是一个独立的page类型的Target应用内可能还有devtools窗口或者隐藏的WebContents。用Node.js请求一下接口你会发现返回的数组里除了常规页面还有个类型为page的target里面有title、url、webSocketDebuggerUrl。rAgent最好先根据窗口的title或者url判断哪个是主窗口再取它的webSocketDebuggerUrl。有一类特殊情况Electron隐藏了BrowserWindow比如托盘应用它们依然会在/json/list里出现webSocketDebuggerUrl也可以连但窗口不可见时CDP的Input事件可能不生效这个我们后面踩坑章节再细说。启动流程的体验优化也很关键我习惯让Agent这样启动先启动Electron应用然后每200毫秒请求一次/json/list直到某个target的title匹配到预期关键词才建立WebSocket连接。这样即使应用启动慢Agent也不会错过连接窗口。2.3 端口暴露的几个安全细节把调试端口暴露出来等于把应用的所有内部状态都开放给了本机进程任何能访问这个端口的程序都能读取密码框内容、执行任意JavaScript。所以至少要注意三点。第一永远用remote-debugging-address参数把监听地址限制在127.0.0.1不然同一局域网内的其他机器会扫到你的调试端口这是很严重的安全风险。第二尽量不要在生产环境长期打开调试端口日常开发和自动化测试可以用环境变量控制开关这也是一开始我在代码里写process.env.AGENT_ENABLED 1这个条件的原因。第三如果应用打包后是分发给其他人的绝不能默认开启调试端口否则你的用户等于把电脑凭据交给了任何能摸到这个端口的人。3. 手写一个能跑的最小CDP客户端3.1 消息协议与请求-响应匹配CDP底层走WebSocket消息格式是JSON。客户端发出去的请求是{id, method, params}id是自增整数服务端回包是{id, result}或{id, error}。服务端主动推送的事件则是{method, params}没有id字段。理解这个模型很重要因为Agent的所有工具函数都要基于这套收发机制。我建议不要一上来就引入重型封装库直接用Node.js的ws库实现一个轻量客户端几十行代码就够还方便理解协议本质。核心逻辑是维护一个pending字典把每个请求的id和Promise的resolve/reject绑定收到响应后再解开const WebSocket require(ws); class CDPClient { constructor(url) { this.ws new WebSocket(url); this.pending new Map(); this.id 0; this.listeners new Map(); this.ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.id ! undefined this.pending.has(msg.id)) { const { resolve, reject } this.pending.get(msg.id); this.pending.delete(msg.id); if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result); } else if (msg.method) { const cbs this.listeners.get(msg.method) || []; cbs.forEach((cb) cb(msg.params)); } }); } send(method, params {}) { const id this.id; return new Promise((resolve, reject) { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } on(method, cb) { if (!this.listeners.has(method)) this.listeners.set(method, []); this.listeners.get(method).push(cb); } // 等待某个事件触发带超时 waitFor(method, timeout 5000) { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(等待事件超时: method)), timeout); this.on(method, (params) { clearTimeout(timer); resolve(params); }); }); } }send函数返回的是一个PromiseAgent可以放心地在异步流程里使用。事件目录独立走listeners让主动推送的事件和普通请求响应互不干扰。还要注意WebSocket连接建立之后Electron可能随时因为页面刷新、窗口关闭等原因断开连接所以我在封装的客户端里还会加一层自动重连断开时清理所有pending里的Promise然后根据预设规则重新获取调试地址并重连。3.2 Agent最常用的四类命令封装跑通连接后日常Agent操作只围绕几类CDP命令展开分别是Runtime.evaluate、Input.dispatchMouseEvent、Input.dispatchKeyEvent和Page.captureScreenshot。Runtime.evaluate是最核心的通用能力它能在目标页面执行任意JavaScript表达式返回值通过result.value拿到底层数据。调用时有两个参数值得注意returnByValue设为true确保返回值可以被JSON序列化awaitPromise设为true允许表达式里返回PromiseAgent可以异步等待某个条件成立。比如读取整个body的文本摘要可以直接传expression为document.body.innerText。Input.dispatchMouseEvent用于派发真实的鼠标事件。它内部有type字段可以取值mouseMoved、mousePressed、mouseReleased后面我还会重点讲为什么这三步必须按顺序来。Input.dispatchKeyEvent则用来模拟键盘但输入中文场景我不推荐逐字模拟直接使用Input.insertText命令一步把字符串插入当前聚焦的输入框效率高得多也解决了中文输入法编码问题。Page.captureScreenshot用于截图返回的图片数据是base64编码。Agent拿到截图后可以交给视觉模型做界面认知也可以保存成文件供人工确认。还有一个不那么显眼但高频使用的命令是Page.bringToFront它能把目标页面拉到前台避免窗口被遮挡导致输入事件丢失。把这四类命令封装成函数后Agent的调用方式就很直观了。例如点击元素封装成clickByIndex(client, index)输入文本封装成fillInputByIndex(client, index, text)截图封装成screenshot(client, savePath)。3.3 等待界面变化的事件订阅机制Agent操作桌面应用和操作网页有个共同点界面状态是异步变化的。点完按钮可能要等网络请求回来、DOM重新渲染然后才出现新元素。如果Agent毫秒级就去读DOM大概率读到的是旧状态。所以最好用事件驱动而非盲目sleep。CDP提供了一系列事件。比如页面开始加载时会推送Page.loadEventFired网络请求发起时会推送Network.requestWillBeSentDOM节点变化会通过DOM域的DOM.childNodeInserted等事件推送。但这些事件非常细碎Agent层面通常不需要裸监听我一般封装成waitFor函数配合轮询条件用。更通用的是写一个“等待元素出现”的工具函数循环执行Runtime.evaluate检查某个元素是否存在直到超时。这种方式虽然简单但胜在稳定不会被各种渲染机制差异干扰。4. 让Agent“看见”界面并精准出手4.1 把大段DOM压缩成决策用的结构化摘要Agent如果想要“看见”一个Electron窗口里有哪些东西可以操作直接扔给它document.body.innerText是不够的。文本里没有控件位置Agent知道有按钮但不知道按钮在哪而且文本可能几千字超过模型上下文窗口响应延迟也会变高。更合适的方案是在页面里执行一段采集脚本把所有可交互元素转成带坐标的JSON数组。我在实际项目里用是一个collectInteractiveElements函数逻辑是遍历常用交互标签button、input、textarea、select、a以及带rolebutton的元素给每个元素打上一个从0开始的>function collectInteractiveElements() { const selectors button, input, textarea, select, a, [rolebutton], [roleoption]; const els Array.from(document.querySelectorAll(selectors)); return els .filter((el) { const r el.getBoundingClientRect(); const style getComputedStyle(el); return r.width 0 r.height 0 style.visibility ! hidden; }) .slice(0, 60) .map((el, i) { el.dataset.agentIndex String(i); const r el.getBoundingClientRect(); return { index: i, tag: el.tagName.toLowerCase(), type: el.getAttribute(type) || , text: (el.innerText || el.value || el.getAttribute(placeholder) || ).trim().slice(0, 60), rect: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } }; }); }Agent拿到的JSON每条都对应一个可点击或可输入的控件包含了唯一编号和精确位置。采集上限60条是为了避免界面元素过多时决策噪音太大也避免后续调用接近上下文限制。这个函数通过Runtime.evaluate在页面里执行返回值就是Agent的“眼睛”。这里的关键技巧是提前给元素打上>async function clickByIndex(client, index) { const findExpr (() { const el document.querySelector([data-agent-index${index}]); if (!el) return null; el.scrollIntoView({ block: center }); const r el.getBoundingClientRect(); return { x: r.x r.width / 2, y: r.y r.height / 2, el: el.outerHTML.slice(0, 100) }; })(); const res await client.send(Runtime.evaluate, { expression: findExpr, returnByValue: true }); if (!res.result || !res.result.value) { throw new Error(元素不存在或不可见: index index); } const { x, y } res.result.value; await client.send(Input.dispatchMouseEvent, { type: mouseMoved, x, y }); await client.send(Input.dispatchMouseEvent, { type: mousePressed, x, y, button: left, clickCount: 1 }); await client.send(Input.dispatchMouseEvent, { type: mouseReleased, x, y, button: left, clickCount: 1 }); } async function fillInputByIndex(client, index, text) { await clickByIndex(client, index); await client.send(Input.insertText, { text }); }实际运行下来这套组合拳覆盖了95%以上的常规操作场景。4.3 截图反馈最后一道视觉防线DOM摘要能告诉Agent“界面上有什么控件”但有些信息是DOM描述不了的按钮的颜色是不是置灰状态列表滚动到什么位置了弹窗的背景遮罩有没有挡住内容。所以我给Agent的设计里永远带一条“截图确认”链路让它做完操作之后截一张图确认视觉效果。截图可以喂给视觉模型做描述也可以和操作前的截图做像素差异对比判断界面是否有变化。想让截图质量稳定建议把窗口尺寸固定。启动Electron应用时设置--window-size1280,800或者让Agent在连接后用Emulation.setDeviceMetricsOverride设置一致的视口尺寸。否则换一台机器、换一个屏幕分辨率截图尺寸变化会导致后续判断逻辑失准。我自己会在测试机上固定--force-device-scale-factor1让CSS像素和物理像素的比例保持1:1截图清晰度和坐标换算都能省很多麻烦。5. 实战让Agent自动回复某聊天工具Demo的消息5.1 场景定义与操作链路设计为了把前面这些原理串起来我基于一个在某公司内部模拟的真实项目X来拆解。这是一个基于Electron的客服聊天工具Demo界面分三块左侧会话列表右侧聊天记录底部输入框和发送按钮。需求是做一个Agent它能感知新消息、读取用户问题、生成回复并发送发送后还能验证消息真的发出去了而不是卡在草稿箱。操作链路设计如下。第一步连接Electron调试端口获取聊天工具Demo那个窗口的webSocketDebuggerUrl。第二步通过collectInteractiveElements读取会话列表找到第一个未读会话并点击。第三步读取聊天窗口区域里的最后一条文本消息方法是限定一个容器selector用Runtime.evaluate取它内部最后一条带消息样式的节点文本。第四步生成回复内容。我这里为了演示用的是简单的关键词匹配规则但结构上完全可以直接替换成调用大模型接口。第五步点击输入框、插入回复文本、点击发送按钮。第六步等消息出现在消息列表中截图确认。这个链路看起来简单但每一步都对应前面讲的CDP能力点。“定位未读会话”依赖结构化采集“点击会话”依赖可信事件“读取最后一条消息”依赖DOM读取“发送回复”依赖插入文本和点击“验证”依赖等待函数和截图。5.2 最小Agent循环观察、决策、执行、验证Agent的核心思想是一个循环观察当前界面根据观察结果选择一个动作执行然后再次观察界面验证动作效果。用CDP作为手脚这个循环可以写成非常直白的代码。我这里给出一个简化的控制流伪代码层面看起来很朴素但跑起来就特别稳定async function agentRun(client, maxRounds 5) { for (let round 0; round maxRounds; round) { // 1. 观察采集可交互元素 读取聊天区内容 const ui await readUI(client); const lastMessage await readLastMessage(client); // 2. 决策判断当前应该做什么 if (ui.hasButton(发送) ui.inputBoxFocused false) { await clickByIndex(client, ui.inputBoxIndex); continue; } if (ui.inputBoxFocused ui.inputBoxText ) { await fillInputByIndex(client, ui.inputBoxIndex, buildReply(lastMessage)); continue; } if (ui.hasButton(发送) ui.inputBoxText ! ) { await clickByIndex(client, ui.sendButtonIndex); } // 3. 验证等待发送按钮左侧的消息片段出现 const sent await waitForSelectorText(client, .message-list .message:last-child, lastMessage.slice(0, 5), 3000); if (sent) break; } await screenshot(client, ./latest.png); }实际项目中intent识别部分可以换成LLM的函数调用把clickByIndex、fillInputByIndex、readUI、screenshot全部注册成工具函数大模型根据界面摘要决定调哪个工具、传什么参数。我在这个某聊天工具Demo上这么做过一版效果非常接近真实人类的操作方式每操作一步界面的UI摘要都会刷新大模型在这个新摘要的基础上继续决策。5.3 发送结果验证与异常重试验证环节最容易被忽视但恰恰是自动化系统从“能用”到“可靠”的分水岭。CDP发送点击事件之后并不是一定能看到预期效果。可能按钮被遮罩挡住没点到可能输入框没有真正获得焦点导致发送内容是空的也可能服务端返回错误导致消息没提交。只执行操作不验证的系统本质上还是盲人操作。我在Agent里加了一个等待函数专门用来在DOM中确认某个条件在超时时间内成立async function waitForSelectorText(client, selector, expectedText, timeout 5000) { const start Date.now(); while (Date.now() - start timeout) { const res await client.send(Runtime.evaluate, { expression: (() { const el document.querySelector(${JSON.stringify(selector)}); return el ? el.innerText : ; })(), returnByValue: true }); if (res.result.value res.result.value.includes(expectedText)) { return true; } await new Promise((r) setTimeout(r, 250)); } return false; }如果验证失败我的处理策略不是直接报错而是重试一次“重新点击输入框并重新输入”的完整动作。因为很多失败场景是元素虽然可见但实际不在默认交互区域或者焦点状态丢失。重试时把坐标重新读取一遍是关键因为页面可能已经发生了滚动或布局变化旧坐标大概率失效。6. 跑通了还不够这五个坑最容易翻车6.1 多窗口、窗口重建与target失联Electron应用经常有多个BrowserWindow点击某个按钮后会弹出新窗口或者旧窗口被销毁重建。CDP里每个窗口是一个target/json/list返回的列表会实时变化。如果你的Agent一直握着旧的webSocketDebuggerUrl这个target可能已经被销毁连接随之断开。解决办法是把“获取调试地址”设计成可重试函数每次连接前都重新请求/json/list根据窗口title或url筛选目标。窗口销毁并重建后新窗口的target会生成新的webSocketDebuggerUrlAgent只要每次都刷新列表就能跟上。我还遇到过窗口标题动态变化的情况所以筛选条件最好用url中的特定路径而不是完全依赖title。6.2 页面跳转和渲染进程崩溃导致上下文失效Electron页面如果发生整体跳转原有的执行上下文会全部销毁WebSocket连接可能还在但Runtime.evaluate的执行结果会变成context destroyed之类的错误。遇到这种情况不要试图复用旧上下文直接用CDP的Target域命令重新附加到该页面或者简单粗暴地重新建立一个WebSocket连接。页面跳转后我之前用>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑