第 29 章

爬虫系统:并发与限速

爬虫系统:并发与限速

互联网上存在着海量公开数据,但这些数据并非天然以结构化形式呈现。Web 爬虫(Web Scraper)是将非结构化的 HTML 页面转化为结构化数据的工程工具。无论是价格监控、学术研究、竞品分析,还是构建搜索引擎的索引,爬虫都是不可或缺的基础设施。

但爬虫并非只是"发 HTTP 请求、解析 HTML"这么简单。真实的工业级爬虫面临三个核心挑战:效率(如何在合理时间内抓取大量 URL)、礼貌(如何不压垮目标服务器)和鲁棒性(如何处理网络失败、反爬机制、动态渲染页面)。Go 语言在应对这三个挑战时展现出独特的优势。

本章将从 Go 的并发模型出发,深入讲解 Worker Pool 模式、令牌桶限速、指数退避算法,并通过完整的爬虫实现展示如何将这些组件组合成一个生产就绪的系统。


Level 1 · 为什么 Go 天然适合爬虫

爬虫的瓶颈在哪里

在开始讲 Go 之前,我们需要先理解爬虫的性能瓶颈在哪里。

一个典型的单页面抓取流程是:发起 TCP 连接 → TLS 握手 → 发送 HTTP 请求 → 等待服务器响应 → 接收响应体 → 解析 HTML。在这个流程中,CPU 实际只在"发起连接"和"解析 HTML"时被使用,其余大部分时间程序在等待网络 I/O。

对于一个网络延迟为 100ms、HTML 解析耗时为 5ms 的典型页面,CPU 利用率不足 5%。如果用单线程串行方式抓取,大量 CPU 时间被浪费在等待上。这就是为什么爬虫天然是 I/O 密集型 应用,并发是提升效率的关键。

线程 vs 协程:并发的代价

传统解决方案是多线程。但每个操作系统线程的内存开销约为 1-8MB(主要是固定大小的栈空间),调度由操作系统内核管理,上下文切换需要进入内核态,耗时约 1-10 微秒。对于一个需要维持数千并发连接的爬虫,线程数量会成为瓶颈。

Go 的 goroutine 从根本上解决了这个问题:

这意味着你可以用同步的代码风格写并发逻辑:

// 看起来像同步代码,实际上是协作式并发
resp, err := http.Get(url)  // goroutine 在这里挂起,但OS线程不阻塞
body, _ := io.ReadAll(resp.Body)

Go HTTP 客户端的工程质量

Go 标准库的 net/http 不仅仅是一个简单的 HTTP 封装,它内置了生产级特性:

client := &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        DisableCompression:  false, // 自动处理 gzip
    },
}

理解这些默认值和可调参数,是构建高效爬虫的第一步。


Level 2 · 核心模式与算法

Worker Pool 模式

Worker Pool(工作池)是解决"如何用固定数量的 goroutine 处理无限任务"的经典模式。它解决的核心问题是:防止无限制地创建 goroutine 导致内存耗尽

Channel-based Worker Pool

func crawlWithWorkerPool(urls []string, workerCount int) []Result {
    jobs := make(chan string, len(urls))
    results := make(chan Result, len(urls))

    // 启动固定数量的 worker
    var wg sync.WaitGroup
    for i := 0; i < workerCount; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for url := range jobs {
                result := fetch(url)
                results <- result
            }
        }()
    }

    // 发送所有任务
    for _, url := range urls {
        jobs <- url
    }
    close(jobs)

    // 等待所有 worker 完成后关闭结果 channel
    go func() {
        wg.Wait()
        close(results)
    }()

    // 收集结果
    var all []Result
    for r := range results {
        all = append(all, r)
    }
    return all
}

这里有几个关键设计决策值得解释:

  1. jobs channel 的缓冲区大小设为 len(urls),是为了让主 goroutine 能立即将所有 URL 入队而不阻塞
  2. close(jobs) 后,for url := range jobs 会在 channel 耗尽时自动退出,这是 Go 的惯用法
  3. 用一个独立的 goroutine 等待 WaitGroup 然后关闭 results,避免死锁

Semaphore-based 并发控制

另一种等价方式是用带缓冲 channel 模拟信号量:

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore {
    return make(chan struct{}, n)
}

func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }

func crawlWithSemaphore(urls []string, maxConcurrent int) []Result {
    sem := NewSemaphore(maxConcurrent)
    var mu sync.Mutex
    var results []Result
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            sem.Acquire()
            defer sem.Release()

            result := fetch(u)
            mu.Lock()
            results = append(results, result)
            mu.Unlock()
        }(url)
    }

    wg.Wait()
    return results
}

Semaphore 方式更灵活,每个 URL 对应一个 goroutine,但通过信号量限制并发数。Worker Pool 方式则复用固定数量的 goroutine,减少 goroutine 的创建销毁开销。对于任务数量确定且数量巨大的场景,Worker Pool 更优。

令牌桶限速:golang.org/x/time/rate

礼貌爬虫的核心是速率限制(Rate Limiting)。速率限制的目标是:不管任务队列有多满,对同一个目标服务器的请求速率不超过设定阈值。

令牌桶(Token Bucket)算法是最常用的速率限制算法:

golang.org/x/time/rate 包实现了令牌桶算法:

import "golang.org/x/time/rate"

// 每秒 2 个请求,允许瞬时突发 5 个
limiter := rate.NewLimiter(rate.Limit(2), 5)

func fetchWithRateLimit(ctx context.Context, limiter *rate.Limiter, url string) (*http.Response, error) {
    // Wait 会阻塞直到令牌可用,或 context 取消
    if err := limiter.Wait(ctx); err != nil {
        return nil, err
    }
    return http.Get(url)
}

对于爬虫场景,通常需要 per-host 的限速器,对不同域名使用不同的速率限制:

type HostLimiter struct {
    mu       sync.Mutex
    limiters map[string]*rate.Limiter
    rps      float64 // requests per second
    burst    int
}

func NewHostLimiter(rps float64, burst int) *HostLimiter {
    return &HostLimiter{
        limiters: make(map[string]*rate.Limiter),
        rps:      rps,
        burst:    burst,
    }
}

func (hl *HostLimiter) Get(host string) *rate.Limiter {
    hl.mu.Lock()
    defer hl.mu.Unlock()
    if l, ok := hl.limiters[host]; ok {
        return l
    }
    l := rate.NewLimiter(rate.Limit(hl.rps), hl.burst)
    hl.limiters[host] = l
    return l
}

指数退避与抖动(Exponential Backoff with Jitter)

网络请求不可避免地会失败:服务器超时、429 Too Many Requests、临时网络错误。正确的重试策略是指数退避:每次失败后等待时间翻倍,避免持续轰炸已经过载的服务器。

但纯指数退避有一个问题:如果大量客户端同时重试,它们会在同一时刻重试,形成"雷群效应"(Thundering Herd)。加入随机抖动(Jitter)可以打散重试时间。

import (
    "math"
    "math/rand"
    "time"
)

type BackoffConfig struct {
    InitialDelay time.Duration
    MaxDelay     time.Duration
    Multiplier   float64
    MaxRetries   int
}

var DefaultBackoff = BackoffConfig{
    InitialDelay: 1 * time.Second,
    MaxDelay:     60 * time.Second,
    Multiplier:   2.0,
    MaxRetries:   5,
}

// FullJitter: 在 [0, cap] 范围内均匀随机,cap 是指数增长的上限
func (b BackoffConfig) Wait(attempt int) time.Duration {
    if attempt >= b.MaxRetries {
        return -1 // 超过最大重试次数,不再重试
    }

    // 计算指数退避上限
    cap := float64(b.InitialDelay) * math.Pow(b.Multiplier, float64(attempt))
    if cap > float64(b.MaxDelay) {
        cap = float64(b.MaxDelay)
    }

    // 在 [0, cap] 内均匀随机(Full Jitter)
    jitter := rand.Float64() * cap
    return time.Duration(jitter)
}

func fetchWithRetry(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
    var lastErr error
    for attempt := 0; attempt <= DefaultBackoff.MaxRetries; attempt++ {
        if attempt > 0 {
            wait := DefaultBackoff.Wait(attempt - 1)
            if wait < 0 {
                break
            }
            select {
            case <-time.After(wait):
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }

        resp, err := client.Get(url)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
        if err != nil {
            lastErr = err
        } else {
            resp.Body.Close()
            lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
        }
    }
    return nil, fmt.Errorf("failed after %d attempts: %w", DefaultBackoff.MaxRetries, lastErr)
}

Robots.txt 与爬虫礼貌

robots.txt 是网站声明哪些路径不允许机器人访问的标准协议(RFC 9309)。礼貌爬虫必须遵守它。

import "github.com/temoto/robotstxt"

type RobotsCache struct {
    mu    sync.RWMutex
    cache map[string]*robotstxt.RobotsData
}

func (rc *RobotsCache) IsAllowed(userAgent, rawURL string) bool {
    u, err := url.Parse(rawURL)
    if err != nil {
        return false
    }
    host := u.Scheme + "://" + u.Host

    rc.mu.RLock()
    data, ok := rc.cache[host]
    rc.mu.RUnlock()

    if !ok {
        data = rc.fetchRobots(host)
        rc.mu.Lock()
        rc.cache[host] = data
        rc.mu.Unlock()
    }

    if data == nil {
        return true // 无法获取 robots.txt,允许访问
    }
    return data.TestAgent(u.Path, userAgent)
}

func (rc *RobotsCache) fetchRobots(host string) *robotstxt.RobotsData {
    resp, err := http.Get(host + "/robots.txt")
    if err != nil || resp.StatusCode != 200 {
        return nil
    }
    defer resp.Body.Close()
    data, _ := robotstxt.FromResponse(resp)
    return data
}

Level 3 · 构建完整爬虫

Colly 框架 vs 原生 net/http

colly 是 Go 生态中最流行的爬虫框架,它封装了很多重复逻辑:

import "github.com/gocolly/colly/v2"

func scrapeWithColly(startURL string) []Article {
    var articles []Article
    var mu sync.Mutex

    c := colly.NewCollector(
        colly.AllowedDomains("example.com"),
        colly.MaxDepth(3),
        colly.Async(true),
    )

    // 设置限速
    c.Limit(&colly.LimitRule{
        DomainGlob:  "*",
        Parallelism: 5,
        Delay:       500 * time.Millisecond,
        RandomDelay: 200 * time.Millisecond,
    })

    // 解析文章列表
    c.OnHTML("article.post", func(e *colly.HTMLElement) {
        article := Article{
            Title: e.ChildText("h2.title"),
            URL:   e.ChildAttr("a.read-more", "href"),
            Date:  e.ChildText("time"),
        }
        mu.Lock()
        articles = append(articles, article)
        mu.Unlock()

        // 跟随链接
        c.Visit(e.Request.AbsoluteURL(article.URL))
    })

    c.OnError(func(r *colly.Response, err error) {
        log.Printf("Error scraping %s: %v", r.Request.URL, err)
    })

    c.Visit(startURL)
    c.Wait()
    return articles
}

但 colly 有其局限性:难以实现精细的重试逻辑、自定义限速策略。对于复杂场景,原生 net/http + goquery 更灵活。

HTML 解析:goquery 与 golang.org/x/net/html

goquery 提供 jQuery 风格的 CSS 选择器接口,底层使用 golang.org/x/net/html 解析树:

import (
    "github.com/PuerkitoBio/goquery"
    "golang.org/x/net/html"
    "strings"
    "net/http"
)

type Article struct {
    Title   string
    URL     string
    Content string
    Date    string
    Tags    []string
}

func parseArticlePage(resp *http.Response) (*Article, error) {
    doc, err := goquery.NewDocumentFromReader(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("parse HTML: %w", err)
    }

    article := &Article{}

    // CSS 选择器获取文本
    article.Title = strings.TrimSpace(doc.Find("h1.article-title").Text())
    article.Date = doc.Find("time[datetime]").AttrOr("datetime", "")

    // 提取所有标签
    doc.Find("a.tag").Each(func(i int, s *goquery.Selection) {
        article.Tags = append(article.Tags, strings.TrimSpace(s.Text()))
    })

    // 提取正文 HTML 并转为纯文本
    contentHTML, _ := doc.Find("div.article-content").Html()
    article.Content = htmlToText(contentHTML)

    return article, nil
}

// 用标准库的 html tokenizer 做简单的 HTML to Text 转换
func htmlToText(htmlStr string) string {
    tokenizer := html.NewTokenizer(strings.NewReader(htmlStr))
    var sb strings.Builder
    for {
        tt := tokenizer.Next()
        switch tt {
        case html.ErrorToken:
            return sb.String()
        case html.TextToken:
            sb.Write(tokenizer.Text())
        case html.StartTagToken:
            tag, _ := tokenizer.TagName()
            if string(tag) == "br" || string(tag) == "p" {
                sb.WriteRune('\n')
            }
        }
    }
}

链接提取与去重:Bloom Filter

爬虫最基本的需求是:已经访问过的 URL 不再重复抓取。最朴素的方式是用 map[string]bool,但当 URL 数量达到数百万时,内存开销变得不可接受。

布隆过滤器(Bloom Filter)是解决这个问题的经典数据结构:用极小的内存(相比 map)判断一个元素是否"可能存在"。它允许少量假阳性(False Positive,即认为已访问但实际未访问),但绝不会有假阴性。

import "github.com/bits-and-blooms/bloom/v3"

type Deduplicator struct {
    filter *bloom.BloomFilter
    mu     sync.Mutex
}

// NewDeduplicator 创建预期容量 n、误判率 fp 的布隆过滤器
func NewDeduplicator(n uint, fp float64) *Deduplicator {
    return &Deduplicator{
        filter: bloom.NewWithEstimates(n, fp),
    }
}

// SeenOrAdd 返回 true 表示已见过(跳过),false 表示首次见到(加入过滤器)
func (d *Deduplicator) SeenOrAdd(url string) bool {
    d.mu.Lock()
    defer d.mu.Unlock()
    if d.filter.TestString(url) {
        return true // 已见过
    }
    d.filter.AddString(url)
    return false
}

func extractLinks(doc *goquery.Document, baseURL *url.URL) []string {
    var links []string
    doc.Find("a[href]").Each(func(i int, s *goquery.Selection) {
        href, exists := s.Attr("href")
        if !exists {
            return
        }
        // 解析为绝对 URL
        u, err := baseURL.Parse(href)
        if err != nil {
            return
        }
        // 只保留同域名、HTTP/HTTPS 链接
        if (u.Scheme == "http" || u.Scheme == "https") && u.Host == baseURL.Host {
            // 移除 fragment(#section)
            u.Fragment = ""
            links = append(links, u.String())
        }
    })
    return links
}

完整爬虫实现:组合所有组件

package scraper

import (
    "context"
    "encoding/csv"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "sync"
    "time"

    "github.com/PuerkitoBio/goquery"
    "github.com/bits-and-blooms/bloom/v3"
    "golang.org/x/time/rate"
)

type Scraper struct {
    client      *http.Client
    limiter     *HostLimiter
    dedup       *Deduplicator
    robots      *RobotsCache
    workers     int
    userAgent   string
}

func NewScraper(rps float64, workers int) *Scraper {
    return &Scraper{
        client: &http.Client{
            Timeout: 30 * time.Second,
            Transport: &http.Transport{
                MaxIdleConnsPerHost: 10,
                IdleConnTimeout:     90 * time.Second,
            },
        },
        limiter:   NewHostLimiter(rps, int(rps*2)),
        dedup:     NewDeduplicator(1_000_000, 0.01), // 100万URL,1%误判率
        robots:    &RobotsCache{cache: make(map[string]*robotstxt.RobotsData)},
        workers:   workers,
        userAgent: "MyBot/1.0 (+https://example.com/bot)",
    }
}

type Job struct {
    URL   string
    Depth int
}

type Result struct {
    URL     string
    Article *Article
    Err     error
}

func (s *Scraper) Run(ctx context.Context, seedURLs []string, maxDepth int) []Article {
    jobs := make(chan Job, 10000)
    results := make(chan Result, 10000)

    // 发送种子 URL
    go func() {
        for _, u := range seedURLs {
            if !s.dedup.SeenOrAdd(u) {
                jobs <- Job{URL: u, Depth: 0}
            }
        }
    }()

    // 启动 workers
    var wg sync.WaitGroup
    for i := 0; i < s.workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                s.processJob(ctx, job, jobs, results, maxDepth)
            }
        }()
    }

    // 收集结果(在独立 goroutine 中)
    var articles []Article
    var collectWg sync.WaitGroup
    collectWg.Add(1)
    go func() {
        defer collectWg.Done()
        for r := range results {
            if r.Err != nil {
                log.Printf("Error: %s: %v", r.URL, r.Err)
                continue
            }
            if r.Article != nil {
                articles = append(articles, *r.Article)
            }
        }
    }()

    wg.Wait()
    close(results)
    collectWg.Wait()

    return articles
}

func (s *Scraper) processJob(ctx context.Context, job Job, jobs chan<- Job, results chan<- Result, maxDepth int) {
    // 检查 robots.txt
    if !s.robots.IsAllowed(s.userAgent, job.URL) {
        return
    }

    // 限速
    u, _ := url.Parse(job.URL)
    limiter := s.limiter.Get(u.Host)
    if err := limiter.Wait(ctx); err != nil {
        return
    }

    // 发起请求(带重试)
    resp, err := fetchWithRetry(ctx, s.client, job.URL)
    if err != nil {
        results <- Result{URL: job.URL, Err: err}
        return
    }
    defer resp.Body.Close()

    doc, err := goquery.NewDocumentFromReader(resp.Body)
    if err != nil {
        results <- Result{URL: job.URL, Err: err}
        return
    }

    article, _ := parseArticlePage(resp)
    results <- Result{URL: job.URL, Article: article}

    // 提取新链接(未超过最大深度)
    if job.Depth < maxDepth {
        links := extractLinks(doc, u)
        for _, link := range links {
            if !s.dedup.SeenOrAdd(link) {
                select {
                case jobs <- Job{URL: link, Depth: job.Depth + 1}:
                default:
                    // 队列满,跳过(防止阻塞)
                }
            }
        }
    }
}

// 保存为 JSON
func SaveJSON(articles []Article, path string) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()
    enc := json.NewEncoder(f)
    enc.SetIndent("", "  ")
    return enc.Encode(articles)
}

// 保存为 CSV
func SaveCSV(articles []Article, path string) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()

    w := csv.NewWriter(f)
    defer w.Flush()

    // 写入表头
    w.Write([]string{"URL", "Title", "Date", "Tags"})

    for _, a := range articles {
        w.Write([]string{
            a.URL,
            a.Title,
            a.Date,
            strings.Join(a.Tags, "|"),
        })
    }
    return w.Error()
}

Level 4 · 高级主题与边缘情况

分布式爬虫:Redis 工作队列

当爬虫规模超过单机能力时,需要将任务队列外置到 Redis,实现多机协作:

import "github.com/redis/go-redis/v9"

type RedisQueue struct {
    rdb    *redis.Client
    key    string
    dedup  string // 去重用的 Redis Set
}

func NewRedisQueue(addr, queueKey, dedupKey string) *RedisQueue {
    return &RedisQueue{
        rdb:   redis.NewClient(&redis.Options{Addr: addr}),
        key:   queueKey,
        dedup: dedupKey,
    }
}

// Push 将 URL 加入队列(如果未见过)
func (q *RedisQueue) Push(ctx context.Context, urls ...string) error {
    pipe := q.rdb.Pipeline()
    var members []interface{}
    for _, u := range urls {
        members = append(members, u)
    }
    // SADD 返回实际新增的数量,已存在的被跳过
    pipe.SAdd(ctx, q.dedup, members...)
    // 使用 Lua 脚本保证原子性:只将 SADD 成功的 URL 加入队列
    // 简化版:直接推入,依赖 SADD 去重
    for _, u := range urls {
        pipe.RPush(ctx, q.key, u)
    }
    _, err := pipe.Exec(ctx)
    return err
}

// Pop 从队列取出一个 URL(阻塞等待)
func (q *RedisQueue) Pop(ctx context.Context, timeout time.Duration) (string, error) {
    result, err := q.rdb.BLPop(ctx, timeout, q.key).Result()
    if err != nil {
        return "", err
    }
    if len(result) < 2 {
        return "", fmt.Errorf("unexpected result")
    }
    return result[1], nil
}

分布式爬虫的关键问题是去重的一致性。上面使用 Redis Set 做全局去重,保证多个爬虫进程不会重复抓取同一 URL。但 Redis Set 在 URL 数量达到亿级时内存开销巨大,实际生产中常用 Redis 上的 Bloom Filter 模块(RedisBloom)。

处理 JavaScript 渲染页面:chromedp

许多现代网站使用 React/Vue 等前端框架,HTML 内容在 JavaScript 执行后才生成。此类页面无法用静态 HTTP 请求抓取,需要无头浏览器:

import (
    "context"
    "github.com/chromedp/chromedp"
    "time"
)

func scrapeJSPage(targetURL string) (string, error) {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    var htmlContent string
    err := chromedp.Run(ctx,
        chromedp.Navigate(targetURL),
        // 等待特定元素出现(意味着 JS 已渲染完成)
        chromedp.WaitVisible("#main-content", chromedp.ByID),
        // 获取完整渲染后的 HTML
        chromedp.OuterHTML("html", &htmlContent),
    )
    if err != nil {
        return "", fmt.Errorf("chromedp: %w", err)
    }
    return htmlContent, nil
}

// 模拟用户滚动以触发懒加载
func scrapeInfiniteScroll(targetURL string) ([]string, error) {
    ctx, cancel := chromedp.NewContext(context.Background())
    defer cancel()

    var items []string
    err := chromedp.Run(ctx,
        chromedp.Navigate(targetURL),
        chromedp.ActionFunc(func(ctx context.Context) error {
            for i := 0; i < 5; i++ {
                // 滚动到页面底部
                chromedp.Evaluate(`window.scrollTo(0, document.body.scrollHeight)`, nil).Do(ctx)
                time.Sleep(2 * time.Second) // 等待新内容加载

                // 提取当前可见的所有 item
                var newItems []string
                chromedp.Evaluate(`
                    Array.from(document.querySelectorAll('.item-title')).map(e => e.textContent)
                `, &newItems).Do(ctx)
                items = newItems
            }
            return nil
        }),
    )
    return items, err
}

chromedp 的性能代价显著高于普通 HTTP 请求(每个浏览器实例约消耗 100-300MB 内存),因此应当只对确实需要 JS 渲染的页面使用。可以先用普通 HTTP 请求尝试,如果发现关键内容缺失,再降级到 chromedp。

代理轮换与 TLS 指纹管理

高级反爬系统不仅分析请求频率,还会检测 TLS 握手特征(JA3 指纹)、HTTP/2 帧的顺序等协议层面的特征。标准 Go HTTP 客户端的 TLS 指纹是固定的,很容易被识别。

import (
    "net/url"
    "net/http"
)

type ProxyPool struct {
    proxies []string
    mu      sync.Mutex
    index   int
}

func (p *ProxyPool) Next() *url.URL {
    p.mu.Lock()
    defer p.mu.Unlock()
    proxy := p.proxies[p.index%len(p.proxies)]
    p.index++
    u, _ := url.Parse(proxy)
    return u
}

func newClientWithProxy(proxyURL *url.URL) *http.Client {
    return &http.Client{
        Transport: &http.Transport{
            Proxy: http.ProxyURL(proxyURL),
        },
        Timeout: 30 * time.Second,
    }
}

对于 TLS 指纹伪装,可以使用 github.com/refraction-networking/utls 库模拟 Chrome 或 Firefox 的 TLS 握手指纹。但这进入了攻防博弈的灰色地带,具体应用需要遵守目标网站的服务条款。

结构化数据提取:JSON-LD 与 Microdata

许多网站为了 SEO,在页面中内嵌了结构化数据(Schema.org 格式),可以直接解析而无需处理复杂的 HTML:

import (
    "encoding/json"
    "strings"
    "github.com/PuerkitoBio/goquery"
)

// JSON-LD 解析(最常见的结构化数据格式)
func extractJSONLD(doc *goquery.Document) map[string]interface{} {
    var result map[string]interface{}
    doc.Find(`script[type="application/ld+json"]`).Each(func(i int, s *goquery.Selection) {
        if result != nil {
            return
        }
        var data map[string]interface{}
        if err := json.Unmarshal([]byte(s.Text()), &data); err == nil {
            result = data
        }
    })
    return result
}

// 使用示例:提取文章的结构化信息
func extractArticleMetadata(doc *goquery.Document) *Article {
    ld := extractJSONLD(doc)
    if ld == nil {
        return nil
    }

    article := &Article{}
    if name, ok := ld["name"].(string); ok {
        article.Title = name
    }
    if datePublished, ok := ld["datePublished"].(string); ok {
        article.Date = datePublished
    }
    if author, ok := ld["author"].(map[string]interface{}); ok {
        if name, ok := author["name"].(string); ok {
            _ = name // 作者信息
        }
    }
    return article
}

JSON-LD 解析比 CSS 选择器更稳定,因为结构化数据的格式变化比 HTML 结构变化慢得多。在编写爬虫时,优先尝试提取 JSON-LD,再降级到 HTML 解析,是一个合理的策略。

关键工程总结

构建一个生产级 Go 爬虫,需要掌握以下核心原则:

  1. 并发控制:用 Worker Pool 或 Semaphore 限制 goroutine 数量,防止内存耗尽
  2. 速率限制:用令牌桶(x/time/rate)做 per-host 限速,尊重服务器承载能力
  3. 错误处理:用指数退避 + 抖动重试,避免雷群效应
  4. 去重:小规模用 in-memory Bloom Filter,大规模用 Redis + RedisBloom
  5. 礼貌:遵守 robots.txt,设置合理的 User-Agent
  6. 可观测性:记录每个 URL 的状态(成功/失败/跳过),方便事后分析
  7. 渐进式:先用静态 HTTP,再用 chromedp,按需升级

Go 的 goroutine 模型使得编写高并发爬虫变得自然而简洁。一个设计良好的 Go 爬虫,用 200 行核心代码就能达到 Python 爬虫 2000 行才能实现的并发性能和工程质量。

本章评分
4.9  / 5  (3 评分)

💬 留言讨论