Go 爬虫实战——colly 框架、并发控制、反爬虫应对、数据存储
Go 爬虫实战——colly 框架、并发控制、反爬虫应对、数据存储
适读人群:需要做数据采集的 Go 工程师、从 Python scrapy 迁移的开发者 | 阅读时长:约19分钟 | 核心价值:掌握 colly 框架的核心用法,以及应对反爬虫措施的实战技巧
一次帮朋友"抢票"让我入了 Go 爬虫的坑
2022年夏天,一个做数据分析的朋友找到我,说需要定期采集某平台的商品价格数据,用于做竞品分析,问能不能帮他写一个爬虫。
我在 Java 里用过 Jsoup、HttpClient,也了解 Python 的 scrapy,但 Go 爬虫是头一次写。
第一版写出来,对方说:"你这个跑了20分钟就被封了,IP 被拉黑了,采集成功率才30%。"
我花了一周时间研究反爬虫的应对策略,又花了两周不断调优,最终把采集成功率提到了92%,运行稳定了几个月没有被封。
这篇文章把那段时间的经验完整写出来。
colly vs 自己写 http.Client
Go 爬虫有两种思路:
- 直接用
net/http+ HTML 解析库(golang.org/x/net/html或github.com/PuerkitoBio/goquery) - 用爬虫框架
colly
建议: 需要爬多个页面、有链接跟踪、有深度控制时,用 colly;只是单纯发几个 HTTP 请求,直接用 http.Client 更简单。
go get github.com/gocolly/colly/v2
go get github.com/PuerkitoBio/goquery # colly 内置了 goquery,一般不需要单独安装colly 基础用法
package main
import (
"fmt"
"log"
"github.com/gocolly/colly/v2"
)
func main() {
// 创建 Collector(爬虫实例)
c := colly.NewCollector(
// 只爬取指定域名的页面,防止爬到不相关的网站
colly.AllowedDomains("example.com", "www.example.com"),
// 最大深度(防止无限递归跟踪链接)
colly.MaxDepth(3),
// 异步模式(并发爬取)
colly.Async(true),
)
// 当 HTML 元素匹配时触发(CSS 选择器语法,和 jQuery 一样)
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
link := e.Attr("href")
fmt.Printf("发现链接: %s\n", link)
// 跟踪链接,继续爬取
e.Request.Visit(e.Request.AbsoluteURL(link))
})
// 商品标题
c.OnHTML(".product-title", func(e *colly.HTMLElement) {
fmt.Printf("商品: %s\n", e.Text)
})
// 请求前触发(可以设置 header、cookie)
c.OnRequest(func(r *colly.Request) {
r.Headers.Set("User-Agent", "Mozilla/5.0 ...")
fmt.Printf("访问: %s\n", r.URL)
})
// 响应到达后触发
c.OnResponse(func(r *colly.Response) {
fmt.Printf("状态: %d, 大小: %d bytes\n", r.StatusCode, len(r.Body))
})
// 错误处理
c.OnError(func(r *colly.Response, err error) {
log.Printf("爬取失败: url=%s, status=%d, err=%v", r.Request.URL, r.StatusCode, err)
})
// 开始爬取
c.Visit("https://example.com")
// 等待所有并发任务完成(Async 模式下必须调用)
c.Wait()
}实战:商品价格采集器
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gocolly/colly/v2"
"github.com/gocolly/colly/v2/extensions"
"github.com/gocolly/colly/v2/queue"
)
// ProductInfo 采集到的商品信息
type ProductInfo struct {
ProductID string `json:"product_id"`
Name string `json:"name"`
Price float64 `json:"price"`
Category string `json:"category"`
URL string `json:"url"`
CrawledAt time.Time `json:"crawled_at"`
}
type ProductCrawler struct {
collector *colly.Collector
results []*ProductInfo
mu sync.Mutex
outputFile *os.File
}
func NewProductCrawler() *ProductCrawler {
c := colly.NewCollector(
colly.AllowedDomains("shop.example.com"),
colly.MaxDepth(2),
colly.Async(true),
// 使用内存缓存(避免重复爬取同一 URL)
// 生产中可以换成 Redis 缓存
)
// 限制请求速率(最重要的反爬配置之一)
c.Limit(&colly.LimitRule{
DomainGlob: "*",
Parallelism: 5, // 最大5个并发请求
Delay: 1 * time.Second, // 每次请求间隔1秒
RandomDelay: 2 * time.Second, // 在基础延迟上随机增加0-2秒
})
// 随机 User-Agent(模拟真实浏览器)
extensions.RandomUserAgent(c)
// 随机 Accept-Language
extensions.Referer(c)
crawler := &ProductCrawler{
collector: c,
results: make([]*ProductInfo, 0),
}
crawler.setupHandlers()
return crawler
}
func (pc *ProductCrawler) setupHandlers() {
c := pc.collector
// 解析商品列表页,找到所有商品链接
c.OnHTML(".product-list .product-item a", func(e *colly.HTMLElement) {
productURL := e.Request.AbsoluteURL(e.Attr("href"))
// 访问商品详情页
e.Request.Visit(productURL)
})
// 解析商品详情页
c.OnHTML(".product-detail", func(e *colly.HTMLElement) {
priceStr := e.ChildText(".price")
// 清理价格字符串(去掉"¥"、空格、逗号等)
priceStr = strings.TrimPrefix(priceStr, "¥")
priceStr = strings.ReplaceAll(priceStr, ",", "")
priceStr = strings.TrimSpace(priceStr)
price, _ := strconv.ParseFloat(priceStr, 64)
product := &ProductInfo{
ProductID: e.ChildAttr("[data-product-id]", "data-product-id"),
Name: e.ChildText(".product-name"),
Price: price,
Category: e.ChildText(".breadcrumb .current"),
URL: e.Request.URL.String(),
CrawledAt: time.Now(),
}
pc.mu.Lock()
pc.results = append(pc.results, product)
pc.mu.Unlock()
if len(pc.results)%100 == 0 {
log.Printf("已采集 %d 条商品数据", len(pc.results))
pc.flushToFile()
}
})
// 处理分页:找到"下一页"链接
c.OnHTML(".pagination .next a", func(e *colly.HTMLElement) {
nextPage := e.Request.AbsoluteURL(e.Attr("href"))
e.Request.Visit(nextPage)
})
c.OnRequest(func(r *colly.Request) {
// 设置常用 header,模拟真实浏览器
r.Headers.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
r.Headers.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
r.Headers.Set("Cache-Control", "no-cache")
r.Headers.Set("Pragma", "no-cache")
if verbose {
log.Printf("[Request] %s", r.URL)
}
})
c.OnError(func(r *colly.Response, err error) {
if r.StatusCode == 429 {
// 被限流,等待更长时间后重试
log.Printf("被限流 (429),暂停30秒: %s", r.Request.URL)
time.Sleep(30 * time.Second)
r.Request.Retry()
return
}
if r.StatusCode == 403 {
log.Printf("被拒绝 (403),可能被封IP: %s", r.Request.URL)
return
}
log.Printf("请求失败: url=%s, status=%d, err=%v", r.Request.URL, r.StatusCode, err)
})
}
func (pc *ProductCrawler) flushToFile() {
pc.mu.Lock()
data := make([]*ProductInfo, len(pc.results))
copy(data, pc.results)
pc.mu.Unlock()
// 追加写入 JSONL 格式(每行一个 JSON 对象,便于增量处理)
f, err := os.OpenFile("products.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("写入文件失败: %v", err)
return
}
defer f.Close()
encoder := json.NewEncoder(f)
for _, p := range data {
encoder.Encode(p)
}
}
var verbose = false
func main() {
crawler := NewProductCrawler()
// 从多个入口页面开始爬取
startURLs := []string{
"https://shop.example.com/category/electronics",
"https://shop.example.com/category/clothing",
}
for _, url := range startURLs {
crawler.collector.Visit(url)
}
crawler.collector.Wait()
crawler.flushToFile()
fmt.Printf("采集完成,共 %d 条商品数据\n", len(crawler.results))
}踩坑实录
坑1:并发太高被 IP 封禁,Parallelism 设置不生效
现象: 设置了 Parallelism: 5,但实际并发远超过5,因为没有设置 DomainGlob,限流规则没有匹配到任何域名。
原因: LimitRule 的 DomainGlob 是必填的,如果不设置或者不匹配,限流规则不生效。
解法: 确保 DomainGlob 能匹配你要爬的域名,不确定就用 "*" 匹配所有:
c.Limit(&colly.LimitRule{
DomainGlob: "*", // 匹配所有域名
Parallelism: 5,
RandomDelay: 2 * time.Second,
})坑2:动态加载的内容爬不到(JavaScript 渲染的页面)
现象: 商品价格和标题是 JavaScript 动态加载的,colly 拿到的 HTML 里没有这些数据。
原因: colly 是基于 HTTP 请求的爬虫,不执行 JavaScript。很多现代网站用 SPA(React/Vue)框架,关键数据通过 AJAX 加载。
解法三种:
- 找 API:打开浏览器 DevTools Network 面板,找到加载数据的 XHR/Fetch 请求,直接调那个 API。这是最简单高效的方案,大多数网站都能用。
- 爬 JS 里的初始数据:很多 SPA 把初始数据内嵌在 HTML 的
<script>里(window.__INITIAL_STATE__之类),用正则提取。 - 用无头浏览器:用
chromedp库驱动 Chrome 执行 JavaScript 再提取内容,适合上面两种都不行的情况,但性能低、资源消耗大。
坑3:Goroutine 泄漏,内存持续增长
现象: 爬虫跑了几个小时后,内存从100MB涨到4GB,goroutine 数量持续增加。
原因: 每次 Visit() 都可能启动新的 goroutine,如果 HTML 里有大量链接,会无限制地创建 goroutine,内存耗尽。
解法: 使用 colly 的 Queue 功能,限制最大并发请求数:
q, _ := queue.New(5, &queue.InMemoryQueueStorage{MaxSize: 10000})
q.AddURL("https://example.com")
q.Run(c)数据存储:JSONL vs SQLite vs MySQL
| 存储方式 | 适合场景 |
|---|---|
| JSONL 文件 | 一次性采集,数据量 < 500万行,后续用 Python 分析 |
| SQLite | 中小量数据,需要简单查询,不想依赖外部数据库 |
| MySQL/PostgreSQL | 大量数据,需要复杂查询,多个系统共享 |
爬虫输出我推荐 JSONL(每行一个 JSON 对象),理由:
- 不需要先定义 schema,字段可以灵活变化
- 方便增量写入(追加文件,不需要改库表结构)
- 可以直接用 Python pandas
read_json(lines=True)分析
