三十的博客

Go 爬虫入门

本文内容基于 AI 生成结果整理,可能包含不准确信息,仅供参考使用。
发布时间
阅读量 加载中...

通过模仿玩游戏打怪升级的方式,简单入门 Go 爬虫。

第一关:新手村 - 单线程爬虫

目标: 学会最基本的网页抓取

go
// 最简单的爬虫:只能打一个怪(单线程)
package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    // 打第一个小怪:访问百度
    resp, err := http.Get("https://www.baidu.com")
    if err != nil {
        panic(err) // 被打败了
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("打败了小怪!获得%d字节的战利品\n", len(body))
}

技能获得:

练习: 修改 URL,尝试访问其他网站

第二关:学会连招 - 抓取多个页面

目标: 一次打多个怪

go
func main() {
    // 要打的怪物列表
    urls := []string{
        "https://www.baidu.com",
        "https://www.qq.com",
        "https://www.taobao.com",
    }

    for i, url := range urls {
        fmt.Printf("正在打第%d个怪: %s\n", i+1, url)
        resp, err := http.Get(url)
        if err != nil {
            fmt.Printf("打怪失败: %v\n", err)
            continue
        }
        defer resp.Body.Close()

        body, _ := io.ReadAll(resp.Body)
        fmt.Printf("打败了%s!获得%d字节\n", url, len(body))
    }
}

发现问题: 一个个打太慢了!需要学习新技能

第三关:召唤分身 - 使用 goroutine

目标: 同时打多个怪

go
func main() {
    urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}

    for _, url := range urls {
        go func(u string) { // go关键字:召唤分身!
            fmt.Printf("分身开始打怪: %s\n", u)
            resp, err := http.Get(u)
            if err != nil {
                fmt.Printf("分身打怪失败: %v\n", err)
                return
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
        }(url) // 把url传给分身
    }

    time.Sleep(5 * time.Second) // 给分身时间完成战斗
}

新问题: 分身太多会把网站打崩!需要控制分身数量

第四关:控制分身 - 使用管道限流

目标: 控制同时战斗的分身数量

go
func main() {
    urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}

    // 创建令牌桶:最多同时3个分身战斗
    tokens := make(chan struct{}, 3)
    for i := 0; i < 3; i++ {
        tokens <- struct{}{} // 放入3个令牌
    }

    for _, url := range urls {
        <-tokens // 领取令牌(如果没有令牌就等待)

        go func(u string) {
            defer func() { tokens <- struct{}{} }() // 战斗完归还令牌

            fmt.Printf("分身开始打怪: %s\n", u)
            resp, err := http.Get(u)
            if err != nil {
                fmt.Printf("分身打怪失败: %v\n", err)
                return
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
        }(url)
    }

    // 等待所有分身归还令牌
    for i := 0; i < 3; i++ {
        <-tokens
    }
}

技能升级: 现在你可以控制战斗节奏了!

🚀 信号量的选择 空结构体 vs 布尔

在 Go 语言的并发模式中,使用 chan struct{} 作为信号量或令牌(token)是一种非常常见且地道的做法,而不是使用 chan bool。

主要原因有以下几点:

  1. 内存占用最小化
  • ​struct{}​:这是一个空结构体。空结构体的一个关键特性是它不占用任何内存 ​(确切地说,大小为 0 字节)。无论你创建多少个 struct{}{},它们都指向同一个内存地址,不会增加额外的内存开销。
  • ​bool​:布尔类型在 Go 中占用 ​1 个字节 ​ 的内存。

当你创建一个 channel 来纯粹传递信号(“有”或“无”),而不需要携带任何实际数据时,使用零内存占用的类型是最经济的选择。虽然对于只有 3 个令牌的场景来说,1 字节 vs 0 字节的差异微乎其微,但在设计模式和代码习惯上,追求最小化开销是一种最佳实践。

  1. 语义清晰化

代码的语义(即代码向阅读者传达的意图)非常重要。

  • chan struct{}​:这种用法通常被称为 ​​“信号量 Channel”​​ 或 ​​“令牌 Channel”​。它的存在仅仅是为了控制并发、实现同步或限制资源访问。当你看到 tokens <- struct{}{} 时,你立刻明白我们只是在投放一个“空信号”或“令牌”,其值本身毫无意义,我们只关心 channel 的“可操作状态”(是否可读/可写)。
  • chan bool​:布尔 channel 通常用于传递一个具有真假含义的状态或标志。例如,它可能用于通知某个计算是否完成(<-done)或某个条件是否成立。使用 bool 会暗示这个 channel 正在传递一个具有逻辑意义的值(true/false),而在这个限流器的上下文中,我们并不需要这样的值。

简单来说:

  • ​struct{}​ 表示:​​“发生了一个事件”​​(我不关心事件的内容,只关心它发生了)。
  • ​bool​ 表示:​​“传递一个真假状态”​​(我关心这个值是 true 还是 false)。
  1. 习惯和惯例

在 Go 的并发编程社区和标准库中,使用 struct{} 作为信号 Channel 的元素类型已经成为一种惯用法(idiom)​。

有经验的 Gopher 一看到 make(chan struct{}, N) 或 done := make(chan struct{}),就能立刻反应过来这是一个用于同步或控制并发的工具,而不是一个用于传输数据的管道。遵循这种惯例可以使你的代码更容易被其他 Go 开发者理解和维护。

第五关:团队协作 - 使用等待组包

目标: 让分身们更好地协作

go
func main() {
    urls := []string{"https://www.baidu.com", "https://www.qq.com", "https://www.taobao.com"}

    tokens := make(chan struct{}, 3)
    for i := 0; i < 3; i++ {
        tokens <- struct{}{}
    }

    var wg sync.WaitGroup // 创建团队协作工具

    for _, url := range urls {
        <-tokens
        wg.Add(1) // 告诉团队:又一个分身出发了

        go func(u string) {
            defer func() {
                tokens <- struct{}{}
                wg.Done() // 告诉团队:我回来了
            }()

            fmt.Printf("分身开始打怪: %s\n", u)
            resp, err := http.Get(u)
            if err != nil {
                fmt.Printf("分身打怪失败: %v\n", err)
                return
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            fmt.Printf("分身打败了%s!获得%d字节\n", u, len(body))
        }(url)
    }

    wg.Wait() // 等待所有分身归来
    fmt.Println("所有分身都完成任务了!")
}

现在你有了一个真正的并发爬虫团队!​

第六关:避免重复 - 记录已访问链接

目标: 不要重复打同一个怪

go
func main() {
    urls := []string{"https://www.baidu.com", "https://www.baidu.com"} // 重复的URL

    visited := make(map[string]bool) // 记录打过的怪
    var mutex sync.Mutex             // 保护记录的锁

    tokens := make(chan struct{}, 3)
    for i := 0; i < 3; i++ {
        tokens <- struct{}{}
    }

    var wg sync.WaitGroup

    for _, url := range urls {
        <-tokens
        wg.Add(1)

        go func(u string) {
            defer func() {
                tokens <- struct{}{}
                wg.Done()
            }()

            // 检查是否打过这个怪
            mutex.Lock()
            if visited[u] {
                mutex.Unlock()
                fmt.Printf("已经打过%s了,跳过\n", u)
                return
            }
            visited[u] = true // 标记为已打
            mutex.Unlock()

            fmt.Printf("开始打新怪: %s\n", u)
            resp, err := http.Get(u)
            if err != nil {
                fmt.Printf("打怪失败: %v\n", err)
                return
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            fmt.Printf("打败了%s!获得%d字节\n", u, len(body))
        }(url)
    }

    wg.Wait()
}

第七关:探索迷宫 - 提取页面链接

目标: 从一个页面找到更多页面

go
func extractLinks(body string) []string {
    var links []string
    // 简单查找href(真实项目应该用goquery等库)
    for i := 0; i < len(body)-6; i++ {
        if body[i:i+6] == "href=\"" {
            i += 6
            end := i
            for end < len(body) && body[end] != '"' {
                end++
            }
            if end > i {
                links = append(links, body[i:end])
            }
        }
    }
    return links
}

// 在打怪成功后添加:
links := extractLinks(string(body))
fmt.Printf("在%s中发现%d个新怪物!\n", u, len(links))

第八关:组建远征军 - 完整并发爬虫

目标: 把所有技能组合起来

go
type Crawler struct {
    maxWorkers int
    visited    map[string]bool
    mutex      sync.Mutex
    wg         sync.WaitGroup
    tokens     chan struct{}
}

func NewCrawler(workers int) *Crawler {
    c := &Crawler{
        maxWorkers: workers,
        visited:    make(map[string]bool),
        tokens:     make(chan struct{}, workers),
    }
    for i := 0; i < workers; i++ {
        c.tokens <- struct{}{}
    }
    return c
}

func (c *Crawler) Crawl(url string) {
    <-c.tokens
    c.wg.Add(1)

    go func(u string) {
        defer func() {
            c.tokens <- struct{}{}
            c.wg.Done()
        }()

        // 检查是否访问过
        c.mutex.Lock()
        if c.visited[u] {
            c.mutex.Unlock()
            return
        }
        c.visited[u] = true
        c.mutex.Unlock()

        // 抓取页面
        resp, err := http.Get(u)
        if err != nil {
            return
        }
        defer resp.Body.Close()

        body, _ := ioutil.ReadAll(resp.Body)
        links := extractLinks(string(body))

        // 递归抓取新链接
        for _, link := range links {
            c.Crawl(link) // 派出新的分身!
        }
    }(url)
}

func (c *Crawler) Wait() {
    c.wg.Wait()
}

func main() {
    crawler := NewCrawler(5) // 组建5人远征军
    crawler.Crawl("https://www.example.com") // 开始冒险!
    crawler.Wait() // 等待远征军归来
    fmt.Println("冒险结束!")
}

第九关:应对特殊怪物 - 错误和超时

目标: 处理各种异常情况

go
func (c *Crawler) crawlWithTimeout(u string) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
    client := &http.Client{
        Timeout: 10 * time.Second,
    }

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            fmt.Printf("打怪超时: %s\n", u)
        }
        return
    }
    defer resp.Body.Close()

    // ... 其余逻辑
}
#爬虫 #Golang