我们的利益:我们是 Geonode,卖代理,而浏览器自动化是你能通过代理做的最吃带宽的事。无头浏览器会抓取每一张图、字体、脚本和视频预加载,所以用按量流量跑 chromedp,成本大约比同一页面的裸 HTTP 请求高一个数量级。 有一节专门讲如何削减,里面的手法省下的钱,比换更便宜的供应商还多。代理配置本身只有三行,有一个真正的坑,同样会讲到。
chromedp 是什么
项目自称是 “a faster, simpler way to drive browsers supporting the Chrome DevTools Protocol in Go without external dependencies”。
最后半句才是卖点。Selenium 需要与浏览器版本匹配的 driver 二进制;Playwright 自带运行时。chromedp 通过 websocket 直接说 DevTools Protocol,所以一个 Go 二进制加上一套 Chrome 安装就是全部部署。
MIT 许可,维护活跃——0.15.1 于 2026 年 4 月发布,提交持续到 7 月,核对于 2026 年 9 月。
安装平淡无奇:
go get -u github.com/chromedp/chromedp
生成的协议绑定在配套包 github.com/chromedp/cdproto 里,高层 API 没包住的东西就去那里。
Context 模型
要先理解的部分,因为其余都从这里来。
chromedp 用 context.Context 同时干两件事:取消(Go 一贯如此),以及携带浏览器和标签页句柄。这种双重用途,正是设置看起来那样的原因。
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
var title string
err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com"),
chromedp.Text("h1", &title, chromedp.NodeVisible),
)
第一次 NewContext 会分配一个浏览器。 从它派生的后续 contexts 会在同一浏览器里建新标签,这样跑多页就不必反复付启动成本:
browserCtx, cancelBrowser := chromedp.NewContext(context.Background())
defer cancelBrowser()
tabCtx, cancelTab := chromedp.NewContext(browserCtx)
defer cancelTab()
取消会关掉东西。 取消标签 context 关掉标签;取消浏览器 context 关掉浏览器。defer cancel() 不是可有可无的记账——省略会泄漏 Chrome 进程。
超时按普通 Go 方式组合:
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
项目自己的 FAQ 里有两个错误值得提前知道。
“Executing an action without Run results in 'invalid context'.” FAQ 解释 “by default, a chromedp context does not have an executor, however one can be specified manually if necessary”。Actions 不会自己执行——它们是 Run 去执行的值。
“I'm seeing 'context canceled' errors.” FAQ 把这归因于连接丢失:“when the connection to the browser is lost, chromedp cancels the context, and it may result in this error. This occurs, for example, if the browser is closed manually, or if the browser process has been killed or otherwise terminated.” 所以 context canceled 常常意味着 Chrome 死了,而不是超时触发——在加大超时之前值得先分清。
Actions
Run 接受一串 actions,按顺序执行。常见的那几个覆盖大部分工作。
err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com/search"),
chromedp.WaitVisible(`input[name="q"]`),
chromedp.SendKeys(`input[name="q"]`, "golang"),
chromedp.Click(`button[type="submit"]`, chromedp.NodeVisible),
chromedp.WaitVisible(`.results`),
chromedp.Text(`.results`, &results, chromedp.NodeVisible),
)
从多个元素提取用 Nodes 或 Evaluate:
var links []string
err := chromedp.Run(ctx,
chromedp.Navigate(url),
chromedp.Evaluate(`[...document.querySelectorAll('a')].map(a => a.href)`, &links),
)
Evaluate 在页面里跑 JavaScript,并把结果反序列化成 Go 值,对一次涉及多个元素的事常常是最短路径。值必须可 JSON 序列化。
对返回多个值的 actions,FAQ 给出包装:
chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error {
_, err := domain.SomeAction().Do(ctx)
return err
}))
ActionFunc 也是你下到原始 cdproto 调用的方式,用于高层 API 没覆盖的事——设 cookie、拦截网络请求、模拟设备。这条逃生舱覆盖整个 DevTools Protocol,那是一块很大的面。
正确等待
可靠爬虫和脆弱爬虫的差别,错误永远是同一个。
不要 sleep。 chromedp.Sleep(3*time.Second) 存在,很诱人,要么太短——慢的一天间歇失败——要么太长,每次运行都浪费时间。通常两者都有,取决于机器。
等待你真正关心的东西:
chromedp.WaitVisible(`.results`, chromedp.ByQuery)
chromedp.WaitNotVisible(`.spinner`)
chromedp.WaitReady(`#content`)
WaitVisible 等元素存在并且可见;WaitReady 等它出现在 DOM 里。交互后加载的内容,可见通常才是正确条件。
选择器表达不了的条件,在页面里轮询:
chromedp.Poll(`document.querySelectorAll('.item').length >= 20`, nil)
这就是“等到列表加载完”的答案,基于元素的等待表达不了。
始终用 context 超时给等待设上界。 对永远匹配不上的选择器做 WaitVisible,会阻塞到 context 过期;没有超时就是永远。
无头运行,以及在 Docker 里
Chrome 默认无头。FAQ 回答人们的第一个问题:“By default, Chrome is run in headless mode. See DefaultExecAllocatorOptions, and an example to override the default options.”
开发时想看着它工作:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
NewExecAllocator 是设置 Chrome 命令行标志的地方,也是浏览器 context 之上那一层。
对容器,项目的建议很具体:“The simplest way is to run the Go program that uses chromedp inside the chromedp/headless-shell image. That image contains headless-shell, a smaller headless build of Chrome, which chromedp is able to find out of the box.”
值得照做。在容器里亲手拼一套能用的 Chrome,意味着追缺失的共享库和字体包,结果比专用镜像还大。
FAQ 里一条 Linux 特有行为,会让单独跑 Chrome 的人吃惊:“On Linux, chromedp is configured to avoid leaking resources by force-killing any started Chrome child processes. If you need to launch a long-running Chrome instance, manually start Chrome and connect using RemoteAllocator.”
RemoteAllocator 通过 websocket 端点连到已在运行的浏览器,这是共享浏览器池、或浏览器跑在另一容器里的模式。
使用代理
三行,一个坑。
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ProxyServer("http://proxy.example.com:9000"),
)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
ProxyServer 设置 Chrome 的 --proxy-server 标志。
坑在认证。 Chrome 的 --proxy-server 不接受凭据——URL 里带用户名密码不会认证。Chrome 对代理质询会弹对话框,无头浏览器没人去填。
两个绕法,按偏好顺序。
用 IP 白名单。 源地址稳定就向供应商登记,彻底丢掉凭据。这是最干净的答案,是去掉问题而不是绕开。
处理认证事件。 chromedp 可通过带 handleAuthRequests 的 fetch.Enable 响应 DevTools Protocol 的认证请求,用程序提供凭据。代码更多,地址不固定时走这条。
然后验证真的生效了,因为浏览器里配错的代理是静默的:
var ip string
err := chromedp.Run(ctx,
chromedp.Navigate("https://api.ipify.org"),
chromedp.Text("body", &ip, chromedp.NodeVisible),
)
有代理选项和没有都跑一遍。地址不变,就是 Chrome 没用上——而且什么都不会告诉你。做按地区定向的工作,还要再确认地区性内容确实不同,因为地址只是容易的那一半。这就是我们在 为什么测试代理很重要 里写过的静默失败模式。
顺便让 locale 匹配出口国家。 德国出口配 en-US 语言头和伦敦时区,是真实访客不会产生的组合,很多站点按 locale 而不是地址来:
chromedp.Flag("lang", "de-DE"),
削减带宽
最能省钱的一节,适用于任何浏览器自动化。
200 KB 的 HTML 页面,把每张图、字体、跟踪脚本和视频预加载都抓下来可能变成 4 MB。按住宅价格每 GB 0.79 美元——我们的数字,核对于 2026 年 9 月的 定价页——这差额就是全部预算。
拦截你不需要的资源类型。 用 fetch.Enable 和 request-paused 监听器,中止图片、媒体和字体请求:
chromedp.ListenTarget(ctx, func(ev interface{}) {
if e, ok := ev.(*fetch.EventRequestPaused); ok {
go func() {
c := chromedp.FromContext(ctx)
execCtx := cdp.WithExecutor(ctx, c.Target)
switch e.ResourceType {
case network.ResourceTypeImage, network.ResourceTypeMedia, network.ResourceTypeFont:
_ = fetch.FailRequest(e.RequestID, network.ErrorReasonBlockedByClient).Do(execCtx)
default:
_ = fetch.ContinueRequest(e.RequestID).Do(execCtx)
}
}()
}
})
这通常能砍掉大部分流量,顺带加快运行。
复用浏览器,创建标签。 启动浏览器贵;标签便宜。跑很多页时,分配一次,再派生标签 contexts。
再问一句:到底需不需要浏览器。 内容在初始 HTML 里,普通 HTTP 请求成本只是零头,也快得多。伸手自动化之前先看页面源码——把一切都渲染出来,是这个领域最常见的多余成本来源。
什么都不工作时如何调试
浏览器自动化失败得很不透明——永远匹配不上的选择器和永远加载不完的页面,超时是一样的。固定顺序能解决大部分。
把浏览器打开看着。 最快的诊断,人们却留到最后:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
)
一半时候答案立刻可见——cookie 横幅盖住按钮、跳到登录页、挑战页,或布局和你测过的不一样。
运行失败时捕获页面。 无头环境里这代替观看:
var buf []byte
_ = chromedp.Run(ctx, chromedp.FullScreenshot(&buf, 90))
_ = os.WriteFile("failure.png", buf, 0644)
配上 HTML,截图显示渲染了什么,源码显示到达了什么:
var html string
_ = chromedp.Run(ctx, chromedp.OuterHTML("html", &html, chromedp.ByQuery))
先确认选择器到底能不能解析,再假设是时序问题:
var count int
_ = chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('.item').length`, &count))
零就是选择器问题,等多久都修不好。
怀疑页面自己在报错时打开浏览器日志:
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("enable-logging", true),
chromedp.Flag("v", "1"),
)
监听控制台消息和失败请求,常常能解释为什么页面渲染是空的:
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
case *runtime.EventConsoleAPICalled:
log.Printf("console.%s", e.Type)
case *network.EventLoadingFailed:
log.Printf("failed: %s %s", e.Type, e.ErrorText)
}
})
API 调用全失败的页面,看起来和选择器改了的页面一模一样,只有网络事件能区分。
最后手段用 chromedp-proxy。 它夹在程序和浏览器之间,双向记录 DevTools Protocol 流量。行为完全说不通时,看真实协议交换通常一遍就能解释。
何时该选 chromedp
在这些情况下用它: 你已经在写 Go,想要一个无需分发 driver 的静态二进制;或者你需要高层工具没有暴露的直接 DevTools Protocol 访问。
在这些情况下考虑 Playwright: 要跨浏览器、每个 action 自带自动等待、失败时追踪和截图,或更大的文档和示例体量。Go 移植存在,但生态以 JavaScript 和 Python 为中心。
内容在初始响应里时考虑普通 HTTP。 更快、更便宜、更简单;网上能直接给出有用 HTML 的页面,比舆论暗示的更多。
FAQ 自己的资源列表是不错的下一站地图:examples 仓库讲复杂 actions 和整页截图,cdproto 参考讲生成的协议 API,chromedp-proxy——CDP 日志代理——看程序和浏览器到底在说什么,这是最后手段的调试工具,而且确实好用。
常见问题
chromedp 是什么?
一个用 Chrome DevTools Protocol 驱动浏览器的 Go 包,没有外部依赖。不像 Selenium 需要 driver 二进制,也不像 Playwright 自带运行时——一个 Go 二进制加上 Chrome 安装就是全部部署。
为什么 chromedp 里会出现 “invalid context”?
因为你在没有 Run 的情况下执行了 action。FAQ 解释 chromedp context 默认没有 executor。Actions 是 chromedp.Run 去执行的值;直接调用没有东西去跑它。
chromedp 里 “context canceled” 是什么意思?
通常是浏览器连接丢了。FAQ 归因于浏览器被手动关掉或进程被杀。值得和超时区分,因为修法不同——崩溃的 Chrome 不是多等一会儿就能好。
如何用可见浏览器跑 chromedp?
Chrome 默认无头。把 chromedp.Flag("headless", false) 追加到 DefaultExecAllocatorOptions,传给 NewExecAllocator,再从该 allocator 派生 context。
如何给 chromedp 配代理?
把 chromedp.ProxyServer("http://host:port") 加到 allocator 选项。URL 里的凭据无效,因为 Chrome 的 --proxy-server 不接受它们——地址稳定就用 IP 白名单,否则通过 DevTools Protocol 处理认证请求。
如何在 Docker 里跑 chromedp?
把 Go 程序跑在 chromedp/headless-shell 镜像里,项目明确推荐。它内含 chromedp 无需配置就能找到的更小无头 Chrome 构建,也避免亲手拼浏览器环境。
如何在 chromedp 里等待元素?
用带选择器的 WaitVisible、WaitReady 或 WaitNotVisible;选择器表达不了的条件用带 JavaScript 表达式的 Poll。避免 Sleep——要么太短太脆,要么太长浪费,通常两种都有,取决于机器。
用 chromedp 时如何减少带宽?
启用请求拦截并让图片、字体、媒体这些资源类型失败,通常能去掉大部分流量。复用一个浏览器、创建标签,而不是反复分配。并检查内容是否在初始 HTML 里,若是就完全跳过浏览器。
总结
chromedp 的学习曲线几乎全在 context 模型。一旦内化:context 携带浏览器或标签、取消就会关掉它、actions 直到 Run 执行才做事,其余 API 就直白了。
要紧的习惯和任何浏览器自动化一样。等条件而不是 sleep,每次等待都用 context 超时设上界,跨许多标签复用一个浏览器,而不是反复付启动成本。
两个 Go 特有的点值得记住。每个 context 都 defer cancel(),否则泄漏 Chrome 进程——在 Linux 上,chromedp 会强杀它启动的 Chrome 子进程,所以长时间运行的浏览器需要单独启动,再用 RemoteAllocator 连上。
如果走按量代理,先拦截不需要的资源类型,再做别的。渲染后的页面比它所含 HTML 贵一个数量级,其中大多是你根本不会看的图片。
