用 agent-browser 驱动 Chrome CDP:在平台容器中启用 Agent 浏览器自动化
Chrome DevTools Protocol(CDP)允许您通过 HTTP / WebSocket 远程控制 Chrome:打开网页、执行 JavaScript、模拟键盘鼠标、抓取 DOM、生成截图等。在「仅 SSH 可达」的平台容器环境里,CDP + SSH 端口转发可以让本地工具/Agent 安全地操作远端浏览器。如果您希望让 Agent 更快、更稳地完成真实网页交互,推荐搭配 agent-browser:它提供「snapshot(可访问性树)+ refs(稳定引用)」的工作流,让 Agent 几乎不需要手写选择器或 CDP 细节,就能执行点击、输入与截图。
您将完成什么
- 在容器里安装
google-chrome-stable(Deb 包,不依赖 Snap) - 安装 CJK 字体,确保截图中文不变成「豆腐」方块
- 在
tmux中启动 headless Chrome,并启用 CDP(默认端口9222) - 通过 SSH 端口转发,在本地访问
http://127.0.0.1:9222/json/version - 用 CDP 验证「可操作」:读标题、连接 WebSocket 执行 CDP 命令并截图
- (可选)安装 Node.js 并使用
agent-browser连接 CDP,完成 snapshot/点击/截图(更适合 Agent)
适用场景
- Agent 需要可编程浏览器:表单提交、搜索、点击按钮、执行 JS、抓取页面内容
- 远端网络/环境限制:浏览器必须运行在容器里,但控制端在本地
- 需要稳定截图:对页面渲染(含中文)有要求,用于报告/回归测试/采集
- 希望用 CLI 快速落地:用
agent-browser(snapshot + refs)减少选择器/脚本维护成本
开始之前(安全与约束)
警告
不要暴露 CDP 到公网/内网
CDP 等价于「远程控制浏览器」。请务必只监听 127.0.0.1,并通过 SSH 端口转发访问;不要让 9222 直接监听 0.0.0.0。
警告
root 运行 Chrome 需要 --no-sandbox
平台容器中默认以 root 用户运行。Chrome 在 Linux 上会拒绝以 root + sandbox 模式启动,因此需要 --no-sandbox。 这会降低安全性:请避免访问不可信网站/打开不可信文件。若条件允许,优先使用非 root 用户运行 Chrome。
前提条件:
- 一个可 SSH 登录的 Ubuntu 容器(本文以 Ubuntu 22.04 为例)
- 能访问外网下载 Chrome
.deb - 容器内具备
tmux(建议),以及python3(用于验证脚本)
Step 1 安装最小依赖与中文字体
注意
- 您不需要安装桌面环境(XFCE4/Xorg/VNC)就能使用 CDP。本文只依赖 headless Chrome。
- 考虑到截图可能包含中文,因此必须安装中文字体。
在容器中执行:
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
wget \
fontconfig \
fonts-liberation \
fonts-noto-cjk \
tmux \
python3
# 刷新字体缓存(避免截图仍然缺字)
fc-cache -fv验证字体(应能看到 Noto Sans CJK):
fc-list | grep -i "Noto Sans CJK" | head -n 5Step 2 安装 Google Chrome(Deb 包)
注意
Ubuntu 22.04 上 chromium-browser 常常来自 Snap;在受限容器里 Snap/squashfs 可能不可用。 因此这里直接安装官方 Deb 的 google-chrome-stable。
wget -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
dpkg -i /tmp/chrome.deb || apt-get install -y -f
rm -f /tmp/chrome.deb
google-chrome-stable --versionStep 3 在 tmux 中启动 headless Chrome + CDP
CDP 默认端口使用 9222。建议把关键路径写到一个 info 文件,方便排查和清理。
PORT=9222
INFO=/tmp/chrome-cdp-$PORT.info
LOG=/tmp/chrome-cdp-$PORT.log
# 进入 tmux(若已在 tmux 里,可跳过)
tmux new -s chrome-cdp-$PORT
# 在 tmux 里执行下面命令(建议复制整段)
DIR=$(mktemp -d /tmp/chrome-cdp-profile.$PORT.XXXXXX)
printf 'port=%s\nprofile_dir=%s\nlog=%s\n' "$PORT" "$DIR" "$LOG" | tee "$INFO"
google-chrome-stable \
--headless=new \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
--no-first-run \
--no-default-browser-check \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port="$PORT" \
--user-data-dir="$DIR" \
about:blank \
2>&1 | tee -a "$LOG"提示
要让 Chrome 在后台继续运行:在 tmux 中按 Ctrl-b 再按 d(detach)。 以后随时用 tmux attach -t chrome-cdp-9222 回来查看日志/停止服务。
注意
Headless Chrome 不会在 VNC/桌面里出现窗口,这是预期行为。
Step 4 通过 SSH 端口转发访问 CDP(本地控制端)
如果您的 Agent/工具运行在本地电脑,需要先建立隧道:
ssh -N -L 9222:localhost:9222 -p <SSH_PORT> root@guangdong-b-is.cloud.infini-ai.com然后在本地打开:
http://127.0.0.1:9222/json/version
注意
http://127.0.0.1:9222/ 显示空白页面通常是正常的;关键是 json/version 与 json/list 是否可访问。
Step 5 验证 CDP「可操作」
Chrome 新版本对 json/new、json/close 这类「有副作用」的接口要求使用 PUT。 如果您用 GET 访问并收到 HTTP 405 Method Not Allowed,请改用 PUT。
本步骤提供两个独立示例:
- 示例 1:打开网页并读取标题(只用 HTTP 接口,最小依赖)
- 示例 2:连接 DevTools WebSocket 执行 CDP 命令并截图(更接近 Agent 的真实用法)
示例 1:打开网页并读取标题
在本地或容器内执行下面脚本(只要能访问 http://127.0.0.1:9222 即可):
python3 - <<'PY'
import json, time, urllib.parse, urllib.request
BASE = "http://127.0.0.1:9222"
URL = "https://example.com/"
# 1) 新建一个 tab(PUT /json/new)
req = urllib.request.Request(
BASE + "/json/new?" + urllib.parse.quote(URL, safe=":/?&=#"),
method="PUT",
)
created = json.load(urllib.request.urlopen(req, timeout=10))
tid = created["id"]
print("created id:", tid)
# 2) 轮询 /json/list,直到页面 title 可用
for _ in range(80):
targets = json.load(urllib.request.urlopen(BASE + "/json/list", timeout=10))
t = next((x for x in targets if x.get("id") == tid), None)
if t and t.get("title"):
print("url:", t.get("url"))
print("title:", t.get("title"))
break
time.sleep(0.25)
else:
raise SystemExit("page did not finish loading in time")
# 3) 关闭 tab(PUT /json/close/<id>)
urllib.request.urlopen(
urllib.request.Request(BASE + "/json/close/" + tid, method="PUT"),
timeout=10,
)
print("closed")
PY如果您看到 title: Example Domain,就说明 CDP 可用且能驱动浏览器访问网页。
示例 2:用 CDP 打开网页并截图(保存到脚本所在机器)
注意
截图会保存在哪里?
CDP 的 Page.captureScreenshot 会把 PNG 以 base64 数据返回给客户端。因此 截图保存的位置取决于脚本运行的位置:
- 在本地运行脚本(通过 SSH 端口转发访问 CDP):PNG 保存在本地。
- 在 SSH 主机/容器内运行脚本:PNG 保存在远端(例如
/tmp)。
注意
WebSocket 连接返回 403 Forbidden
新版 Chrome 会校验 DevTools WebSocket 的 Origin 头。如果您的 CDP 客户端默认发送 Origin,可能会收到 403 Forbidden。
解决方法(二选一):
- 推荐:让客户端不要发送
Origin。例如 Pythonwebsocket-client可使用suppress_origin=True。 - 可选:启动 Chrome 时增加
--remote-allow-origins=http://127.0.0.1(同时仍需确保只监听127.0.0.1,不要暴露到公网/内网)。
安装依赖(Ubuntu):
apt-get update && apt-get install -y --no-install-recommends python3-websocket运行脚本(示例:访问 news.qq.com,截图首页,并随机打开一个链接再截图一次):
python3 - <<'PY'
import base64
import json
import random
import time
import urllib.parse
import urllib.request
from websocket import create_connection
BASE_HTTP = "http://127.0.0.1:9222"
HOME_URL = "https://news.qq.com/"
DENY_HOSTS = {
"v.qq.com",
"passport.qq.com",
"qzone.qq.com",
"mail.qq.com",
}
def http_json(path: str, method: str = "GET"):
req = urllib.request.Request(BASE_HTTP + path, method=method)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)
class CDP:
def __init__(self, ws_url: str):
# websocket-client 会默认发送 Origin;新版 Chrome 会拒绝未放行的 Origin。
# suppress_origin=True 会移除 Origin 头,避免 403。
self.ws = create_connection(ws_url, timeout=30, suppress_origin=True)
self.next_id = 1
def close(self):
try:
self.ws.close()
except Exception:
pass
def send(self, method: str, params=None, timeout_s: float = 30.0):
msg_id = self.next_id
self.next_id += 1
payload = {"id": msg_id, "method": method}
if params is not None:
payload["params"] = params
self.ws.send(json.dumps(payload))
deadline = time.time() + timeout_s
while True:
if time.time() > deadline:
raise TimeoutError(f"timeout waiting for response: {method}")
msg = json.loads(self.ws.recv())
# 忽略事件与其他请求的返回
if msg.get("id") != msg_id:
continue
if "error" in msg:
raise RuntimeError(msg["error"].get("message", json.dumps(msg["error"])))
return msg.get("result")
def wait_event(self, event_method: str, timeout_s: float = 30.0):
deadline = time.time() + timeout_s
while True:
if time.time() > deadline:
raise TimeoutError(f"timeout waiting for event: {event_method}")
msg = json.loads(self.ws.recv())
if msg.get("method") == event_method:
return msg.get("params")
def eval(self, expression: str):
res = self.send(
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
},
)
return (res or {}).get("result", {}).get("value")
def screenshot_png(self, out_path: str):
res = self.send(
"Page.captureScreenshot",
{
"format": "png",
"fromSurface": True,
"captureBeyondViewport": True,
},
)
data = (res or {}).get("data", "")
with open(out_path, "wb") as f:
f.write(base64.b64decode(data))
def get_candidates(cdp: CDP):
js = r"""(() => {
const seen = new Set();
const out = [];
for (const a of document.querySelectorAll('a[href]')) {
const href = a.href;
let text = (a.innerText || a.textContent || '').trim();
if (!href || !href.startsWith('http')) continue;
if (!text) continue;
text = text.replace(/\s+/g, ' ');
if (text.length < 8) continue;
if (seen.has(href)) continue;
seen.add(href);
out.push({ href, text });
if (out.length >= 200) break;
}
return out;
})()"""
items = cdp.eval(js) or []
cleaned = []
for it in items:
if not isinstance(it, dict):
continue
href = it.get("href")
text = it.get("text")
if not href or not text:
continue
host = urllib.parse.urlparse(href).hostname or ""
if not host or host in DENY_HOSTS:
continue
# 只保留 qq.com 域名(包含 mp.weixin.qq.com)
if not host.endswith("qq.com"):
continue
cleaned.append({"href": href, "text": text})
return cleaned
def main():
ts = time.strftime("%Y%m%d-%H%M%S")
home_png = f"/tmp/cdp-qq-home-{ts}.png"
article_png = f"/tmp/cdp-qq-article-{ts}.png"
created = http_json(
"/json/new?" + urllib.parse.quote(HOME_URL, safe=":/?&=#"),
method="PUT",
)
tid = created["id"]
ws_url = created["webSocketDebuggerUrl"]
cdp = CDP(ws_url)
picked = None
try:
cdp.send("Page.enable")
cdp.send("Runtime.enable")
cdp.send("Network.enable")
cdp.send(
"Emulation.setDeviceMetricsOverride",
{"width": 1440, "height": 900, "deviceScaleFactor": 1, "mobile": False},
)
# 1) 首页截图
cdp.send("Page.navigate", {"url": HOME_URL})
try:
cdp.wait_event("Page.loadEventFired", timeout_s=30)
except Exception:
pass
time.sleep(2.5)
cdp.screenshot_png(home_png)
# 2) 随机挑选一个链接并截图
candidates = get_candidates(cdp)
if not candidates:
raise SystemExit("no candidate links found on homepage")
picked = random.choice(candidates)
cdp.send("Page.navigate", {"url": picked["href"]})
try:
cdp.wait_event("Page.loadEventFired", timeout_s=30)
except Exception:
pass
time.sleep(2.5)
cdp.screenshot_png(article_png)
print("HOME:", HOME_URL)
print("PICKED_TITLE:", picked["text"])
print("PICKED_URL:", picked["href"])
print("SCREENSHOT_HOME:", home_png)
print("SCREENSHOT_ARTICLE:", article_png)
finally:
try:
http_json("/json/close/" + tid, method="PUT")
except Exception:
pass
cdp.close()
if __name__ == "__main__":
main()
PY提示
如果目标网站触发验证码/安全验证,建议先用稳定测试页(例如 https://example.com/)验证流程,再切换到目标站点,或在脚本中增加「过滤域名/关键词」的逻辑。
Step 6 使用 agent-browser 连接 CDP(推荐给 Agent 的 CLI)
agent-browser 是一个面向 AI Agent 的无头浏览器自动化 CLI:它提供了「snapshot(可访问性树)+ refs(稳定引用)」的工作流,适合让 Agent 在不写 CDP/Playwright 代码的情况下完成点击、输入、截图等操作。
注意
本节将 agent-browser 作为 CDP 客户端 使用:直接连接您在 Step 3 启动的 Chrome(端口 9222)。因此不需要额外下载 Chromium。
安装 Node.js 24(Active LTS)
注意
如果您已经是 root,请移除命令中的 sudo。
# 下载并运行 Node.js 24 安装脚本
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
# 安装 Node.js
sudo apt install -y nodejs
# 验证安装
node -v # 应显示 v24.x.x安装 agent-browser
npm install -g agent-browser
agent-browser --help | head -n 8连接 CDP 并验证(打开网页 → snapshot → 点击 → 截图)
注意
截图会保存在哪里?
agent-browser screenshot /path/to.png 会把文件保存到 运行命令的那台机器。 如果您在 SSH 主机上运行命令,图片就保存在 SSH 主机;如果您在本地运行命令(通过端口转发连接 CDP),图片就保存在本地。
在 SSH 主机/容器内执行(直接连本机 9222):
# 打开页面
agent-browser --cdp 9222 open https://example.com
# 输出可交互元素(带 ref)
agent-browser --cdp 9222 snapshot -i
# 机器可读输出(推荐给 Agent)
agent-browser --cdp 9222 snapshot -i --json
# 使用 ref 点击(示例页通常会出现 link ref=e1)
agent-browser --cdp 9222 click @e1
# 截图保存到远端
agent-browser --cdp 9222 screenshot /tmp/agent-browser-example.png
ls -lh /tmp/agent-browser-example.png如果您看到 snapshot 输出类似下面内容,并且截图文件成功生成,就说明 agent-browser 已经可以稳定连接 CDP 并驱动网页:
- link "Learn more" [ref=e1]提示
如果 agent-browser --cdp 9222 ... 连接失败,请先回到 Step 5 验证 /json/version 是否可访问,并查看「常见问题排查」中的:
连接 webSocketDebuggerUrl 返回 403 Forbidden
(可选)快速验证「截图中文渲染」是否正常
如果您只想快速确认「字体安装正确、渲染不缺字」,可以用 headless Chrome 的 --screenshot 生成一张 PNG(不需要 CDP 客户端):
HTML=/tmp/cjk-font-test.html
PNG=/tmp/cjk-font-test.png
PROFILE=$(mktemp -d /tmp/chrome-screenshot-profile.XXXXXX)
cat > "$HTML" <<'EOF'
<!doctype html>
<meta charset="utf-8" />
<style>
body { font-family: "Noto Sans CJK SC", "Noto Sans CJK", sans-serif; font-size: 48px; padding: 32px; }
</style>
中文渲染测试:您好,世界。ABC 123
EOF
google-chrome-stable \
--headless=new \
--no-sandbox \
--disable-gpu \
--disable-dev-shm-usage \
--user-data-dir="$PROFILE" \
--window-size=1200,400 \
--screenshot="$PNG" \
"file://$HTML"
ls -l "$PNG"您可以用 scp 把图片拷到本地查看:
scp -P <SSH_PORT> root@guangdong-b-is.cloud.infini-ai.com:/tmp/cjk-font-test.png .停止与清理
停止 CDP Chrome(任选其一):
- 回到 tmux:
tmux attach -t chrome-cdp-9222,按Ctrl+C - 直接杀掉会话:
tmux kill-session -t chrome-cdp-9222
清理临时 profile:
DIR=$(sed -n 's/^profile_dir=//p' /tmp/chrome-cdp-9222.info)
[ -n "$DIR" ] && rm -rf "$DIR"常见问题
访问 /json/new 返回 405
新版 Chrome 要求 PUT。请用脚本或 curl -X PUT ...。
启动 Chrome 报错 Running as root without --no-sandbox
这是 root + sandbox 的限制。请加 --no-sandbox,或改用非 root 用户运行(更推荐)。
http://127.0.0.1:9222/ 是白屏
正常现象。请看 /json/version、/json/list。
截图里中文变成方块
确认安装了 fonts-noto-cjk 并执行了 fc-cache -fv。
连接 webSocketDebuggerUrl 返回 403 Forbidden
这是新版 Chrome 的 DevTools WebSocket「Origin 校验」导致的:如果您的客户端默认发送 Origin,Chrome 可能会拒绝连接并返回 403。
解决方法(二选一):
- 推荐:让客户端不要发送
Origin。例如 Pythonwebsocket-client可使用suppress_origin=True。 - 可选:启动 Chrome 时增加
--remote-allow-origins=http://127.0.0.1(同时仍需确保只监听127.0.0.1,不要暴露到公网/内网)。
既然 Chrome 命令行 --screenshot 能出图,为什么 Agent 还需要 CDP?
这里的 --screenshot 指的是 Chrome 的命令行参数。例如:
google-chrome-stable --headless=new --screenshot=/tmp/out.png https://example.com
在 headless 模式下打开一个 URL,并把渲染结果直接保存为 PNG。它适合「打开一个 URL → 截图 → 退出」的单步采集;但 Agent 通常需要 CDP 来完成更复杂、可控的自动化流程,例如:
- 交互操作:点击、输入、滚动、选择、触发 JS 行为(
--screenshot无法完成)。 - 多步骤流程:从首页挑链接、进入详情页、等待某个元素出现后再截图。
- 可观测性与调试:读取 DOM/文本、获取网络请求、捕获 console 输出、精确控制等待条件。
- 复用会话状态:复用同一份 profile/cookie/session,避免每一步都冷启动浏览器。
既然 Agent 可以直接用 CDP,为什么还要用 agent-browser?
两者都能完成网页自动化,但分工不同:
- 直接使用 CDP:更底层、更灵活。适合需要精细控制 DevTools 协议(事件、网络、性能、追踪等)或需要自定义复杂逻辑的场景,但需要您自己处理等待、元素定位、重试与错误恢复等「编排」工作。
- 使用
agent-browser:更偏向「给 Agent 用的工作流」。它把常见操作封装成 CLI,并提供snapshot -i的可访问性树 + refs(例如@e1),让 Agent 用稳定引用进行点击/输入,减少 CSS 选择器的脆弱性,也更容易用--json输出做机器可读集成。
如果您的目标是「让 Agent 快速稳定落地」,优先使用 agent-browser;当您需要更底层能力或超出 CLI 范围的功能时,再回到 CDP 脚本。
Chrome 和 Chromium 有什么区别?我应该选哪个?
- Chromium:开源项目(上游代码库)。
- Google Chrome:基于 Chromium 的发行版,包含品牌与部分专有组件。
对本文这种「容器里启用 CDP 做自动化」的场景,二者都支持 CDP,通常都能工作。主要差异常见于:
- 发行与安装方式:在 Ubuntu 22.04 上,
chromium-browser常见来自 Snap;受限容器里 Snap/squashfs 可能不可用。本文选择安装官方 Deb 的google-chrome-stable,更容易在容器环境落地。 - 媒体/DRM/编解码能力:Chrome 往往包含更完整的专有组件(例如部分编解码/DRM 支持)。如果您的自动化涉及音视频播放或受 DRM 保护内容,Chrome 更可能「开箱即用」。
能否通过某个 HTTP 端口访问 Chrome 浏览器 UI?
**不能。**CDP 端口提供的是「调试与自动化接口」,不是「浏览器窗口 UI」(地址栏、标签页、菜单)。
- 你能从 CDP 端口得到什么:
- 目标列表(例如
/json/version、/json/list) - 每个页面的
webSocketDebuggerUrl(Agent 连接后即可控制网页、执行脚本、截图)
- 目标列表(例如
- 你不能从 CDP 端口得到什么:
- 一个“像本地浏览器一样可点击”的窗口 UI。
--headless模式下 Chrome 不会创建可见窗口,因此也不存在可转发的 UI。
- 一个“像本地浏览器一样可点击”的窗口 UI。
人类如何观察或调试页面?
- 只需要“看页面渲染结果”:用 CDP 的
Page.captureScreenshot(或本文的--screenshot)产出图片,再下载查看。 - 需要检查 DOM/Network/Console:通过 SSH 端口转发访问
http://127.0.0.1:9222/json/list,从返回的devtoolsFrontendUrl打开 DevTools(这是 DevTools,不是浏览器窗口)。 - 需要完整可交互 UI(像本地 Chrome 一样):这属于“远程桌面”能力,通常需要桌面环境 + VNC/noVNC 等服务。可参考:在容器化 Ubuntu 上安装 XFCE4 并配置生产就绪 VNC 环境。
延伸阅读:为什么本文不安装中文输入法(IME)?
本文面向的核心场景是:Agent 通过 CDP 自动化驱动浏览器。在这个场景下,通常不需要在容器里安装中文输入法(IME)。
先区分两个概念:
- 字体(fonts):决定「能否正确渲染中文」(例如截图里是否出现「豆腐」方块)。本文已在 Step 1 安装了 CJK 字体。
- 输入法(IME):决定「人工交互时如何把拼音/五笔变成中文」(候选框、组合输入、快捷键切换等)。
通常不需要 IME 的情况
- 自动化填写表单:CDP/自动化工具可以直接把 Unicode 中文字符串写入输入框(例如设置
value或通过 CDP 的输入事件)。不依赖 IME 的候选框流程。 - 页面渲染/截图/抓取:这类任务只与字体相关,和 IME 无关。
提示
如果您的目标只是「网页里能输入中文」,优先使用自动化直接写入中文文本。这通常更稳定、成本更低。
确实需要 IME 的情况
只有当您满足下面任一条件时,才建议为容器补齐 IME:
- 您需要在远程桌面中像本地一样进行人工输入(拼音/候选框/选词)。
- 您需要测试 IME 的组合输入行为(composition events)、候选词选择、快捷键切换等「输入法流程」本身。
为什么在受限容器里加 IME 会更麻烦
在平台容器中,IME 往往不仅是「装个包」,还依赖一整套图形与会话基础设施:
- 需要图形会话:Headless Chrome 没有可交互的桌面会话,候选框也无处显示。通常需要 Xorg/Wayland + 桌面环境/窗口管理器。
- 需要输入法框架与集成:例如 IBus/Fcitx5,以及应用侧的 IM module(GTK/Qt/XMODIFIERS 等环境配置)。
- 需要稳定的用户会话/DBus:受限容器常常没有
systemd用户会话,输入法守护进程的启动与环境注入会更容易踩坑。 - root 会话不友好:很多桌面/输入法组件更推荐在非 root 用户下运行(也更接近生产使用方式)。
需要「桌面 + 中文输入法」,该如何配置?
如果您的场景必须使用远程桌面进行人工中文输入,建议先搭建完整的桌面与 VNC 环境,再在该环境中配置 IME。您可以参考这篇教程完成桌面化与生产级排障配置: