我搭了一个 AI 智能体,任务是帮我每天维护博客。
需求看着简单:写好文章 → 登录平台 → 提交发布。但实操起来全是坑------
有些平台有反爬,有些有验证码,有些登录态隔天就过期,有些发布接口有奇奇怪怪的校验参数。用网页自动化吧,被反爬拦住;用官方 API 吧,接口不全或者权限不够。
最后衍生出一个有意思的架构:AI 智能体 + Chrome CDP + Xvfb 虚拟显示器。这篇文章拆一下这个方案是怎么落地的。
为什么不用 Playwright / Puppeteer
最初用的 Playwright,挺好的------API 封装完整、选择器方便、还自带 auto-wait。但跑在服务器上遇到两个问题:
1. 反爬标记
Playwright 底层走的也是 Chrome DevTools Protocol,本质也是...无头模式。我们上篇文章验证过,纯无头模式下 navigator.webdriver 为 true、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 智能体获得了和人类几乎一样的浏览器行为,同时保留了对浏览器底层的完全控制权。对于需要长期稳定运行的发文系统、数据采集、或者任何需要浏览器自动化的场景,这是一个值得参考的工程思路。