Go 基础体系 · 第 97/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 爬虫实践:Colly、chromedp、限速与合规边界

本文以 Go 1.26.4、稳定版 github.com/gocolly/colly/v2 v2.3.0github.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 条款调用,不能通过自动化绕开访问控制、验证码、付费墙或技术保护措施。

选择规则应写进任务:staticbrowser 分队列,分别限额、部署和告警。不要在一次 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/OnXMLOnResponseOnErrorOnScraped 等回调。回调注册顺序和触发语义属于程序协议,应该在启动前完成注册,运行中不要并发修改配置。

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上限。

随机延迟用于避免同步尖峰,不是伪装人类或逃避检测。收到 429Retry-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 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。