当AI智能体学会了操控浏览器:Chrome CDP + 自动化Agent实战

我搭了一个 AI 智能体,任务是帮我每天维护博客。

需求看着简单:写好文章 → 登录平台 → 提交发布。但实操起来全是坑------

有些平台有反爬,有些有验证码,有些登录态隔天就过期,有些发布接口有奇奇怪怪的校验参数。用网页自动化吧,被反爬拦住;用官方 API 吧,接口不全或者权限不够。

最后衍生出一个有意思的架构:AI 智能体 + Chrome CDP + Xvfb 虚拟显示器。这篇文章拆一下这个方案是怎么落地的。

为什么不用 Playwright / Puppeteer

最初用的 Playwright,挺好的------API 封装完整、选择器方便、还自带 auto-wait。但跑在服务器上遇到两个问题:

1. 反爬标记

Playwright 底层走的也是 Chrome DevTools Protocol,本质也是...无头模式。我们上篇文章验证过,纯无头模式下 navigator.webdrivertrue、UA 含 HeadlessChrome、WebGL 走软渲染。这些特征在国内平台上会被易盾等反爬 SDK 精准拦截。

2. 上下文开销

在 AI 智能体的场景中,我们不仅要跑浏览器,还要让 AI 理解页面上发生了什么。Playwright 的 accessibility tree 快照动辄几千行,大部分是无关信息。AI 读这些内容的 token 成本很高,而且噪音太多影响判断。

但 CDP 可以做到"只取所需"------想看哪个元素就问哪个元素,按需查询。

架构:AI Agent / CDP / Xvfb 三层

复制代码
┌────────────────────────────────────────┐
│         AI 智能体(Hermes Agent)        │  ← 决策层:选题、写文章、判断下一步
│  Python 脚本控制逻辑                     │
└────────────────┬───────────────────────┘
                 │ CDP WebSocket 指令
┌────────────────▼───────────────────────┐
│         Chrome CDP 层                   │  ← 操控层:点击、输入、拦截、注入
│  DOM.getDocument                        │
│  Input.dispatchMouseEvent               │
│  Network.setRequestInterception         │
│  Runtime.evaluate                       │
└────────────────┬───────────────────────┘
                 │ 渲染请求
┌────────────────▼───────────────────────┐
│  Xvfb 虚拟显示器(:99, 1920x1080)       │  ← 环境层:让 Chrome 以为有桌面
│  google-chrome-stable(有头模式)        │
└────────────────────────────────────────┘

AI 智能体不直接操控浏览器 DOM,而是 通过 Python 脚本发送 CDP 命令,精准控制浏览器的每一项行为。Xvfb 让浏览器以有头模式运行,不暴露自动化标记。

核心代码骨架

用一个分段控制的结构,每步确认后再执行下一步:

python 复制代码
import json, time, urllib.request, websocket

class ChromeAgent:
    """通过 CDP 控制 Chrome 的智能体客户端"""
    
    def __init__(self, port=9222):
        self.port = port
        self.ws = None
        self._connect()
    
    def _connect(self):
        """连接 CDP WebSocket"""
        pages = json.loads(
            urllib.request.urlopen(f"http://localhost:{self.port}/json").read()
        )
        ws_url = pages[0]["webSocketDebuggerUrl"]
        self.ws = websocket.create_connection(ws_url)
        self._send("Page.enable")
        self._send("Runtime.enable")
        self._send("Network.enable")
    
    def _send(self, method, params=None, id=None):
        """发送 CDP 命令"""
        if id is None:
            id = int(time.time() * 1000)
        msg = {"id": id, "method": method}
        if params:
            msg["params"] = params
        self.ws.send(json.dumps(msg))
        return json.loads(self.ws.recv())
    
    def navigate(self, url):
        """导航到 URL"""
        return self._send("Page.navigate", {"url": url})
    
    def click(self, selector):
        """通过 JS 选择器点击元素"""
        return self._send("Runtime.evaluate", {
            "expression": f"""
                (() => {{
                    const el = document.querySelector({json.dumps(selector)});
                    if (!el) return {{error: '元素未找到'}};
                    const rect = el.getBoundingClientRect();
                    el.scrollIntoView({{block: 'center'}});
                    return {{
                        x: rect.x + rect.width/2,
                        y: rect.y + rect.height/2,
                        text: el.textContent.slice(0, 50)
                    }};
                }})()
            """
        })
    
    def type_text(self, selector, text):
        """在输入框中填入文字"""
        # 先聚焦再填入
        self._send("Runtime.evaluate", {
            "expression": f"""
                (() => {{
                    const el = document.querySelector({json.dumps(selector)});
                    if (el) el.focus();
                }})()
            """
        })
        self._send("Input.insertText", {"text": text})
    
    def inject_content(self, html):
        """注入富文本内容(适合编辑器场景)"""
        return self._send("Runtime.evaluate", {
            "expression": f"""
                (() => {{
                    const editor = document.querySelector('.markdown_views, .editor-content, [contenteditable]');
                    if (!editor) return {{error: '未找到编辑器'}};
                    if (editor.tagName === 'TEXTAREA' || editor.tagName === 'INPUT') {{
                        editor.value = {json.dumps(html)};
                        editor.dispatchEvent(new Event('input', {{bubbles: true}}));
                    }} else {{
                        editor.innerHTML = {json.dumps(html)};
                        editor.dispatchEvent(new Event('input', {{bubbles: true}}));
                    }}
                    return {{ok: true}};
                }})()
            """
        })

核心优势:按需查询,省上下文

Playwright 的典型交互方式是 dump 整个页面的 accessibility tree(可能 3000+ 行),然后让 AI 从中找到目标元素。这在大模型中会很浪费 token,而且 accessibility tree 不等于视觉排版,经常出现 AI 看不懂布局的情况。

CDP 的方式更精打细算:

复制代码
AI:  我要点"发布文章"按钮
脚本: → CDP Runtime.evaluate(querySelector("发布"))
      → 返回 {坐标: (120, 340), 文本: "发布文章"}
AI:  确认,执行点击
脚本: → CDP Input.dispatchMouseEvent(x=120, y=340, type="mousePressed")
      → CDP Input.dispatchMouseEvent(x=120, y=340, type="mouseReleased")

每次通信只传输必要的信息。对于 CDP 返回中数据量大的场景(如 DOM.getDocument),还会做二次裁剪:

python 复制代码
def get_clickable_elements(self):
    """只获取页面中可点击元素的信息"""
    result = self._send("Runtime.evaluate", {
        "expression": """
            Array.from(document.querySelectorAll(
                'a, button, input[type=submit], [role=button], .el-button'
            )).map(el => ({
                tag: el.tagName,
                text: el.textContent.trim().slice(0, 30),
                visible: el.offsetParent !== null,
                rect: el.getBoundingClientRect()
            })).filter(el => el.visible)
        """
    })
    return result.get("result", {}).get("value", [])

最终 AI 看到的是类似这样的精简信息:

复制代码
可点击元素(共 8 个):
  [1] button "登录"       → (950, 20) 32×28
  [2] a "CSDN 博客"        → (200, 50) 80×20
  [3] div "发布文章"        → (300, 50) 70×20
  ...

对比 Playwright vs 裸 CDP

维度 Playwright 裸 CDP WebSocket
上手难度 低,API 封装好 高,需理解协议细节
反爬特征 多(带 --headless 取决于启动方式
Token 开销(AI 场景) 高(dump 全量 DOM) 低(按需查询)
请求拦截 支持(route) 原生支持(Network 域)
自愈能力 内置 auto-wait 需要自己实现重试
灵活性 封装层限制了底层操作 所有 CDP 方法都能用
浏览器依赖 需安装 Playwright 浏览器 系统级 Chrome 即可

裸 CDP 不适合纯写脚本的开发者------那太麻烦了。但 AI 智能体本身就是一个"编程层",代码由 AI 生成,由 AI 维护,自愈逻辑也可以由 AI 实现。在这个范式下,裸 CDP 的"操作复杂"这个缺点被 AI 天然弥补了,而保留的精简通信优势则放大了。

登录态持久化

发文章的另一个难点是登录态保持。用 storage_state 方案:

python 复制代码
import os, json

STORAGE_PATH = os.path.expanduser("~/.hermes/storage_state")

def restore_session(agent):
    """通过 CDP 恢复登录态"""
    if not os.path.exists(STORAGE_PATH):
        return False
    
    with open(STORAGE_PATH) as f:
        state = json.load(f)
    
    # 设置 cookies
    for cookie in state.get("cookies", []):
        agent._send("Network.setCookie", {
            "name": cookie["name"],
            "value": cookie["value"],
            "domain": cookie.get("domain", ""),
            "path": cookie.get("path", "/"),
            "secure": cookie.get("secure", False),
            "httpOnly": cookie.get("httpOnly", False)
        })
    
    # 设置 localStorage
    for url, items in state.get("origins", {}).items():
        for key, value in items.get("localStorage", []):
            agent._send("Runtime.evaluate", {
                "expression": f"localStorage.setItem({json.dumps(key)}, {json.dumps(value)})"
            })
    
    return True

这样即使浏览器重启,只要 storage_state 文件还在,登录态就能零操作恢复。

总结

AI 智能体 + Chrome CDP + Xvfb 这套方案的核心思想是:

不要跟反爬在表层硬碰硬。 让浏览器像浏览器(Xvfb + 有头模式),让 AI 像程序员一样精准操控浏览器(CDP WebSocket),而不是像自动化脚本一样遍历 DOM。

这个组合让 AI 智能体获得了和人类几乎一样的浏览器行为,同时保留了对浏览器底层的完全控制权。对于需要长期稳定运行的发文系统、数据采集、或者任何需要浏览器自动化的场景,这是一个值得参考的工程思路。

相关推荐
ch_ziyuan1 小时前
安卓APP报毒自动化解决方案处理系统:动态包名+证书随机+360加固集成(后台源码)
android·运维·自动化
启道张恒1 小时前
飞扬软件「建筑自动化·房间定义」重磅升级:重塑设计效率新标杆
大数据·人工智能·ai设计·bim正向设计·国产二三维设计软件·飞扬集成设计系统
用户5191495848451 小时前
WP NssUser Register 权限提升漏洞利用工具 (CVE-2024-54363)
人工智能·aigc
Elastic 中国社区官方博客1 小时前
Elasticsearch:使用预计算上下文降低 agent 成本
大数据·人工智能·elasticsearch·搜索引擎·ai·全文检索
Lumos_yuan1 小时前
What is data ?
人工智能
码以致用1 小时前
OpenFoundry 开源数据操作系统:架构解析与实战指南
人工智能·ai·架构·开源
m0_715674431 小时前
技术创新突破·可管可控·对标行标 医疗API安全解决方案实践指南
大数据·人工智能·安全
SelectDB技术团队1 小时前
97% 召回率、900 QPS:Apache Doris 4.1 生产级向量检索的工程实践
数据库·人工智能·数据分析·apache doris·selectdb