Go 基础体系 · 第 97/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 爬虫实践:Colly、chromedp、限速与合规边界
本文以 Go 1.26.4、稳定版 github.com/gocolly/colly/v2 v2.3.0 和 github.com/chromedp/chromedp v0.16.0 为基准。Colly 适合请求静态 HTML 并通过回调解析;chromedp 通过 Chrome DevTools Protocol 驱动真实 Chromium,适合内容必须执行 JavaScript 后才能出现的页面。后者资源和攻击面都大得多,不能把“解析不到”直接等价为“启动浏览器”。
可靠爬虫首先是受约束的分布式客户端:它尊重站点声明与法律/合同边界,有明确身份、速率、并发、超时和停止机制;它把 URL、解析版本和抓取结果作为可恢复状态;它不会让不可信页面访问内网、宿主文件或无限消耗浏览器资源。解析选择器只是其中一小部分。
1. 先选公开 API、静态 HTTP 还是浏览器
优先级通常是官方 API/数据导出、静态 HTTP、最后才是浏览器。API 的结构稳定、成本低且授权边界明确;静态请求可缓存、易限速、内存少;浏览器需要进程、渲染器、字体、沙箱和更多网络请求,单页成本可能高一个数量级。
先用浏览器开发工具查看初始 HTML 和 XHR/fetch。若数据已在 HTML、JSON-LD 或公开 JSON 接口中,就不必执行脚本。若接口需要站点明确授权的会话,仍按 API 条款调用,不能通过自动化绕开访问控制、验证码、付费墙或技术保护措施。
选择规则应写进任务:static 与 browser 分队列,分别限额、部署和告警。不要在一次 Colly 解析失败后自动无限升级到浏览器,否则页面改版会突然耗尽集群。
2. 固定版本与项目初始化
mkdir polite-crawler && cd polite-crawler
go mod init example.com/polite-crawler
go get github.com/gocolly/colly/v2@v2.3.0
go get github.com/chromedp/chromedp@v0.16.0
go mod tidy
go test ./...
生产还要固定 Chromium 大版本或容器镜像 digest。chromedp 与浏览器通过协议交互,但版本跨度仍可能导致 action、下载和事件行为变化。构建记录 Go、两个模块、Chrome 版本与启动参数,升级分开灰度。
go.sum 验证模块内容,不替代来源审查。CI 加漏洞扫描、许可证清单和镜像扫描;爬虫处理外部内容,解析库和浏览器安全更新需要比普通后台依赖更快的响应流程。
3. Colly 的回调架构与请求生命周期
Colly 以 Collector 为中心。一次请求经过 OnRequest、响应、OnHTML/OnXML、OnResponse、OnError、OnScraped 等回调。回调注册顺序和触发语义属于程序协议,应该在启动前完成注册,运行中不要并发修改配置。
collector := colly.NewCollector(
colly.AllowedDomains("docs.example.com"),
colly.MaxDepth(3),
colly.UserAgent("WRResearchBot/1.0 (+https://example.com/crawler-policy)"),
)
collector.SetRequestTimeout(12 * time.Second)
collector.OnRequest(func(request *colly.Request) {
request.Headers.Set("Accept", "text/html,application/xhtml+xml")
})
collector.OnHTML("article h2 a[href]", func(element *colly.HTMLElement) {
title := strings.TrimSpace(element.Text)
url := element.Request.AbsoluteURL(element.Attr("href"))
consume(title, url)
})
collector.OnError(func(response *colly.Response, err error) {
recordFailure(response.Request.URL.String(), response.StatusCode, err)
})
if err := collector.Visit("https://docs.example.com/articles"); err != nil {
return fmt.Errorf("visit seed: %w", err)
}
Visit 的返回错误和异步回调错误都要处理。回调中 panic 会破坏进程,解析缺字段应产生结构化失败或跳过原因。不要在 OnHTML 里直接做慢数据库事务;把有限结果送到有背压的持久化层,并明确失败后是否重试页面。
4. 并发、速率和随机延迟是三件事
Parallelism 限制同域并发请求,Delay/RandomDelay 控制请求间隔。并发上限保护连接、内存和对方在途压力;速率限制控制单位时间请求数,不能互相替代。
err := collector.Limit(&colly.LimitRule{
DomainGlob: "*.example.com",
Parallelism: 2,
Delay: 1500 * time.Millisecond,
RandomDelay: 500 * time.Millisecond,
})
if err != nil {
return fmt.Errorf("configure limit: %w", err)
}
同一站点的多个域名、多个进程和多个租户必须共享全局预算,否则每进程“2 并发”会聚合为攻击流量。生产可用中心令牌桶或按站点分区的调度器,且全局还有总连接/CPU上限。
随机延迟用于避免同步尖峰,不是伪装人类或逃避检测。收到 429、Retry-After 或持续 503 应显著退避甚至停站,而不是增加代理和速度。重试必须限定状态、次数、总 deadline,POST 等非幂等动作原则上不属于爬取流程。
5. robots.txt、条款与爬虫礼仪
技术上能请求不代表允许。上线前确认站点条款、robots.txt、版权、隐私、数据库权利和适用法律,必要时取得书面授权。robots 是最低机器声明,不是全部法律许可;允许抓取也不自动允许再发布、训练或保存个人数据。
每个 host 在抓取前获取并缓存 robots 规则,按 user-agent 和 path 判断,遵守 crawl-delay(若业务采用该扩展语义)。robots 获取失败的策略应保守且配置化;不能默认为绕过。缓存有过期时间,状态变化后重新评估。
User-Agent 提供真实产品名、用途与联系人页面。支持站点管理员请求降速、删除或停止。优先增量抓取,使用 ETag/Last-Modified,避免重复下载未变化内容。安排低峰期不是提高总量的借口,服务器声明永远优先。
6. URL 解析、规范化与作用域
URL 去重必须先定义等价关系。移除 fragment 通常安全,因为它不发送给服务器;scheme/host 大小写可规范化;默认端口可折叠。查询参数能否排序、删除 utm_* 或合并尾斜杠取决于站点语义,不能通用猜测。
func normalizeURL(base, raw string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", fmt.Errorf("parse base: %w", err)
}
reference, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse reference: %w", err)
}
resolved := baseURL.ResolveReference(reference)
if resolved.Scheme != "https" {
return "", fmt.Errorf("unsupported scheme %q", resolved.Scheme)
}
resolved.Fragment = ""
resolved.Host = strings.ToLower(resolved.Host)
return resolved.String(), nil
}
AllowedDomains 只能限制主机匹配,不是完整 SSRF 防护。还要解析 DNS 后拒绝 loopback、link-local、私网、云元数据和内部服务地址,并防 DNS rebinding:连接时校验实际目标 IP。重定向每一跳重新执行 scheme、host、IP 和次数检查。
任务 key 通常包含规范 URL、抓取策略版本和身份/语言变体。仅用 URL 去重会错误合并不同地区或授权上下文,但凭据绝不能进入日志 key。
7. 页面解析、数据契约与改版检测
CSS selector 不是数据契约。解析结果应区分:页面成功且没有记录、关键字段缺失、selector 完全未命中、登录页/验证码、HTTP 错误和正文截断。否则页面改版会被误报为“今日数据为零”。
type articleRecord struct {
SourceURL string
Title string
Published time.Time
}
func parseArticle(element *colly.HTMLElement) (articleRecord, error) {
title := strings.TrimSpace(element.ChildText("h1"))
if title == "" {
return articleRecord{}, errors.New("article title is empty")
}
rawTime := element.ChildAttr("time[datetime]", "datetime")
published, err := time.Parse(time.RFC3339, rawTime)
if err != nil {
return articleRecord{}, fmt.Errorf("parse published time %q: %w", rawTime, err)
}
return articleRecord{SourceURL: element.Request.URL.String(), Title: title, Published: published}, nil
}
保留抓取时间、最终 URL、HTTP validators、parser version 和内容摘要。是否保存原始 HTML取决于合规与调试需求;若保存,应加密、限期、控制访问并限制正文大小。Golden fixture 要脱敏并确认有权存入仓库。
监控每站点 selector 命中率、关键字段缺失率、正文大小与重定向分布。变化越界时暂停发布结果并告警,而不是静默写入空字段。
8. 请求上下文、停止与 Colly 异步模式
Colly 可配置 colly.Async(true) 并行请求,结束前调用 collector.Wait()。但 Wait 没有替你定义 context、接纳停止和任务所有权。推荐调度层持有根 context,提交前检查取消,并让 transport 的 request 绑定可取消 context。
func visitAll(ctx context.Context, collector *colly.Collector, urls []string) error {
for _, target := range urls {
select {
case <-ctx.Done():
collector.Wait()
return context.Cause(ctx)
default:
}
if err := collector.Visit(target); err != nil {
return fmt.Errorf("visit %q: %w", target, err)
}
}
collector.Wait()
return nil
}
仅停止提交不能中断已在进行的请求。需要完整取消时使用观察 context 的自定义 http.Transport/请求创建路径,或把每批 Collector 生命周期限定在 context 内。关闭 idle connections,并等待回调完成后再关闭结果 channel。
每个启动 goroutine 的位置都要回答谁取消、谁等待、错误去哪。不要在回调里 fire-and-forget 上传;进程退出会丢失它们。
9. 重试、幂等与持久任务
抓取 GET 通常可重试,但成功响应不等于解析成功,解析失败也不应不加区别地重试。DNS 临时错误、连接复位、408/429/502/503/504 可在总 deadline 内指数退避;401/403/404 通常不自动重试;验证码或封禁应停站人工处理。
持久任务记录规范 URL、站点、尝试次数、not-before、parser version 和最后错误类别。worker 领取需要 lease,崩溃后可重放;写结果以任务 ID/内容摘要幂等 upsert。成功落库后再确认任务,避免“确认后崩溃”丢数据。
队列必须有容量与租户公平性。站点故障时重试会自激,应有每站点熔断和全局重试预算。死信队列不是垃圾桶,需可查询、可重放且重放仍经过当前合规策略。
10. chromedp 的进程与 tab 双层生命周期
chromedp 常见两层 context:ExecAllocator 拥有浏览器进程和启动参数,基于它创建 browser context;每个任务再创建 tab context。取消 tab 关闭目标,取消 allocator 才结束浏览器进程。
allocatorOptions := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-dev-shm-usage", true),
chromedp.NoFirstRun,
chromedp.NoDefaultBrowserCheck,
)
allocatorCtx, allocatorCancel := chromedp.NewExecAllocator(context.Background(), allocatorOptions...)
defer allocatorCancel()
browserCtx, browserCancel := chromedp.NewContext(allocatorCtx)
defer browserCancel()
tabCtx, tabCancel := chromedp.NewContext(browserCtx)
defer tabCancel()
tabCtx, timeoutCancel := context.WithTimeout(tabCtx, 20*time.Second)
defer timeoutCancel()
生产不要为每个 URL 无界启动 Chrome,也不要让一个进程永不回收。采用有硬上限的浏览器池/worker,每 worker 限 tab 数、总页面数、内存和寿命,达到阈值后排空并重启。共享浏览器降低成本,却带来 cookie、缓存、service worker 和崩溃相互影响,隔离级别必须按信任域选择。
11. 导航、等待条件与稳定提取
页面 load 事件不保证业务内容已出现,Sleep 也不保证。等待明确 selector 可见、属性满足或应用的网络/DOM状态,然后在同一 deadline 内提取。
func renderTitle(ctx context.Context, target string) (string, error) {
var title string
err := chromedp.Run(ctx,
chromedp.Navigate(target),
chromedp.WaitVisible(`article h1`, chromedp.ByQuery),
chromedp.Text(`article h1`, &title, chromedp.ByQuery),
)
if err != nil {
return "", fmt.Errorf("render %q: %w", target, err)
}
title = strings.TrimSpace(title)
if title == "" {
return "", errors.New("rendered title is empty")
}
return title, nil
}
SPA 可能持续轮询,等待“网络完全空闲”会永不结束。选择与数据契约相符的条件,设置导航、等待和总任务 deadline。DOM 节点可能重渲染,分开的查找/点击会 stale;将短动作组合,并为可判定瞬时变化限定重试次数。
不要执行来自任务输入的任意 JavaScript。若确实需要 Evaluate,脚本应由应用固定,参数通过结构化值传入,并验证返回类型和大小。
12. 浏览器网络控制、下载与截图
一个页面可加载广告、追踪器、字体、视频和第三方 iframe。通过 CDP 监听请求,按 allowlist 阻止不需要的资源与域名,既降成本也缩小数据外泄面。导航和所有子资源都要受网络策略约束。
截图前固定 viewport、device scale 和字体环境,限制最大像素与输出字节。下载默认禁用;业务确需下载时使用每任务空目录、文件名服务端生成、限制总量和 MIME,等待下载完成后扫描,再移入受控存储。绝不能允许页面决定宿主绝对路径。
HAR、console、HTML和截图可能含 token、个人信息与页面正文。只在明确诊断开关下采集,访问受控、传输加密、保留期短。错误日志不要打印带 query token 的完整 URL。
13. 不可信页面与浏览器隔离
Chrome 沙箱必须启用。网上常见 --no-sandbox 只是为了容器快速启动,不是生产修复;它会把浏览器漏洞直接暴露给宿主。容器需要配置用户 namespace、seccomp、只读根文件系统、非 root 用户、最小 capability 和足够的 /dev/shm,不能以禁用沙箱替代环境修正。
浏览器 worker 应与核心数据库、云元数据和内部控制面网络隔离,出口仅允许目标公网与必要 DNS/代理。拒绝 file:、data:(除非严格内部生成)、ftp: 和自定义 scheme;HTTP 是否允许由策略明确。防止 DNS rebinding和重定向进入私网。
每任务设置 CPU、内存、进程数、文件描述符、磁盘、网络字节和墙钟上限。页面崩溃、OOM 或浏览器失联后销毁整个污染 worker,不把未知状态实例放回池中。不同租户、不同授权会话不能共享 profile 目录。
14. Cookie、登录态与隐私
优先抓公开页面。需要登录时必须有用户/站点授权和服务账号治理,凭据放 secret manager,通过短期 token 注入;不将密码写命令行、镜像或日志。每信任域使用独立 browser context 或临时 user-data-dir,任务结束清理 cookie、localStorage、IndexedDB、cache 和 service worker。
不要导出个人浏览器 profile 给服务端。MFA、验证码和反自动化挑战不是要“破解”的技术障碍,应停下并走授权接口或人工流程。采集个人信息前做目的限制、最小化、保留期、删除和访问审计。
页面内容本身可能包含提示诱导、恶意下载或控制序列。爬取结果进入后续 AI、shell、模板或浏览器时仍是不可信数据,必须在每个输出上下文重新转义,不能因为“是自己爬的”就信任。
15. 诊断与可观测性
核心指标按站点和模式区分:队列等待、请求/导航耗时、HTTP 状态、字节数、重试、robots 拒绝、selector 命中、解析失败、浏览器启动/崩溃/OOM、活跃 tab 与 worker 回收。URL label 必须低基数且脱敏,不能把每个完整 URL 放进指标。
Colly 无结果时检查响应状态、Content-Type、最终 URL、正文是否是登录/挑战页、selector 和字符编码。chromedp 超时时记录停在哪个 action、DOM 摘要和受控截图;Chrome 启动失败检查可执行路径、共享内存、沙箱、字体和架构。
go test ./...
go test -race ./...
go vet ./...
go test -run TestParser -count=20 ./internal/parser
go test -bench=Parse -benchmem ./internal/parser
抓 goroutine/heap/profile 时保护诊断端口。浏览器内存要同时观察 Go 进程、Chrome browser、renderer 与 GPU 子进程,单看 Go heap 会误判。
16. 测试、性能与故障注入
解析器用本地 fixture 和 httptest.Server,测试缺字段、重定向、错误状态、超大正文、字符集和页面改版。robots、URL规范化和重试策略用表驱动测试。测试不访问真实第三方站点,否则不稳定且可能违反礼仪。
Colly 集成测试由 httptest.Server 记录同时连接数和请求时间,断言限速与深度;取消用 channel barrier 控制,不用 Sleep。chromedp 测试固定 Chromium 镜像和本地测试页,覆盖延迟 DOM、永不出现 selector、弹窗、崩溃与下载拒绝。
性能测量分开 HTTP、HTML解析、持久化、浏览器启动、页面渲染。浏览器池吞吐不能只看页/秒,还要 P95/P99、峰值 RSS、每任务网络字节、崩溃率和回收成本。提高 tab 并发可能因 renderer 争用反而变慢。
17. 部署、停止与生产边界
调度器停止时先禁止新任务,等待已领取任务到宽限期;随后取消 Colly 请求/tab,确认 goroutine和 Chrome 子进程退出,再释放队列 lease。超时任务回到队列时保留尝试次数和幂等键。Kubernetes termination grace 必须大于内部清理预算,preStop 不能只 Sleep。
resources:
requests:
cpu: "1"
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
这只是容器基线,Chrome 沙箱、临时目录、共享内存、seccomp 和出口 NetworkPolicy 仍需实际配置与验证。产物固定 Go/依赖/浏览器镜像 digest,生成 SBOM、签名和漏洞响应清单。
生产原则是:用最低能力完成任务,先取得许可再访问;静态请求有站点级预算,浏览器运行在不可信隔离区;每个任务可取消、可重放、可审计且结果可判定。 Colly 和 chromedp 提供执行机制,礼仪、合规、安全与生命周期必须由系统架构补齐。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go Bubble Tea TUI:Elm 架构、键盘事件、异步命令与组件
- 下一篇:Go Excelize 实战:读写 Excel、样式、公式与流式大文件
- 延伸:Go net/http 基础:Server、Handler、Middleware 与 Client 超时
- 延伸:Go context 完整指南:取消、超时、Deadline 与 Value
- 延伸:Go 并发模式:Worker Pool、Pipeline、Fan-out 与背压
- 延伸:Go Web 安全加固:输入边界、TLS、SSRF、注入与供应链
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论