玩转 Go HTTP 客户端系列(番外)—— Goroutine + Channel 爬取抖音合集

GO 异步并发爬取抖音短视频合集

法律意识

仅作为经验交流,不可用于其他用途!

在进行网络爬虫前,了解和遵守相关法律法规至关重要。在互联网上,有一些指导文件被用来规范爬虫的行为,其中包括 robots.txt 文件。

robots.txt 是一个文本文件,用于向搜索引擎和其他网络爬虫提供关于网站访问权限的指示。它告诉爬虫哪些网页可以被访问,哪些不可以。

如果想要对抖音进行爬虫操作,建议首先查看他的 robots.txt 文件,以了解平台对爬虫的规定。网站:www.douyin.com/robots.txt

确定目标

爬取某个抖音合集,批量下载无水印的短视频保存至本地,这里选择使用了《和老王一起看华语乐坛排行》

爬取过程

思路分析

先复制下目标 URL,可以观察到 endpoint 是 1、2、3... 对应着相应的集数,这样非常方便我们去遍历 URL 地址:

打开这个 URL,点击进入是这样的,我们拿到 Media 类型的报文,查看它的 Request URL,这其实就是真正的下载地址,但我们肯定不会手动一个个这样去进行查找并下载,并且这个地址是动态,具有反爬机制的。

我们尝试复制动态的路径到 HTML 源码中查找,在源码中也拿到了渲染后的 URL 下载路径。

一次失败的尝试

注意,这个真正的 URL 虽然在开发者工具中拿到了,但我们可以尝试用代码获取一下:

go 复制代码
package main

import (
    "fmt"
    "net/http"

    "golang.org/x/net/html"
)

// 获取动态生成的媒体资源
func main() {
    // 请求地址
    url := "https://www.douyin.com/collection/7230030857229043749/1"

    // 创建 HTTP 客户端
    client := http.DefaultClient

    // 发送 GET 请求获取抖音内容
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        fmt.Println("创建请求失败:", err)
        return
    }

    // User-Agent请求头
    req.Header.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36")  

    // 发送请求
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("发送请求失败:", err)
        return
    }

    // 解析网页内容  
    doc, err := html.Parse(resp.Body)
    if err != nil {
        fmt.Println("解析网页内容失败:", err)
        return
    }

    // 在解析树中查找第一个<source>标签
    var findSourceURL func(*html.Node) string
    findSourceURL = func(n *html.Node) string {
        if n.Type == html.ElementNode && n.Data == "source" {
            for _, attr := range n.Attr {
                if attr.Key == "src" {
                    return attr.Val
                }
            }
        }
        for c := n.FirstChild; c != nil; c = c.NextSibling {
            if url := findSourceURL(c); url != "" {
                return url
            }
        }
        return ""  
    }

    // 查看结果
    sourceURL := findSourceURL(doc)
    if sourceURL != "" {
        fmt.Println("目标 URL:", sourceURL)
    } else {
        fmt.Println("未找到<source>标签")
    }
}

可想而知,大概率是拿不到预期结果的。这个目标 URL 位于 JavaScript 动态生成的内容中,因此使用 GoHTML 解析器无法提取它。在这种情况下,我们可能需要考虑使用其他方法来获取动态生成的内容。一种方法是使用爬虫技术手段来分析页面,但这不是我们的重点。相反,我们可以选择使用更方便的方式,例如模拟浏览器行为并执行 JavaScript 代码,或使用 Headless 浏览器工具。

使用 Chromedp 模拟 Dom 操作

在这里,我们使用了 Gochromedp 库,以控制 Chromium 浏览器来执行 DOM 操作,从而获取我们所需的内容。

bash 复制代码
go get -u github.com/chromedp/chromedp

先来个同步版本:

go 复制代码
package main
  
import (
    "context"
    "fmt"
    "strconv"
    "strings"
    "time"

    "github.com/chromedp/cdproto/network"
    "github.com/chromedp/chromedp"
)

type Item struct {  
    id int
    url string
    title string
}

func GenURLs() []string {
    baseURL := "https://www.douyin.com/collection/7230030857229043749/"
    urls := make([]string, 0, 39)
    for idx := 1; idx <= 39; idx++ {
        url := baseURL + strconv.Itoa(idx)
        urls = append(urls, url)
    }
    return urls
}

func main() {
    // 配置选项
    opts := append(chromedp.DefaultExecAllocatorOptions[:],
        chromedp.Flag("headless", true), // 无头模式
        chromedp.Flag("disable-gpu", true),
        chromedp.Flag("no-sandbox", false))

    // 创建新的上下文和取消函数
    ctx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
    defer cancel()

    // 创建一个新的Chrome实例
    ctx, cancel = chromedp.NewContext(ctx)
    defer cancel()

    // url 列表
    var results []Item

    // 执行任务列表
    for _, url := range GenURLs() {
        var res Item
        err := chromedp.Run(ctx,
            ChromeActions(url, &res),
        )
        if err != nil {
            fmt.Printf("执行任务失败:%v\n", err)
            continue
        }
        fmt.Println("结果:", res)
        results = append(results, res)
    }

    // 处理所有结果
    fmt.Println("所有结果:", results)
}

func ChromeActions(host string, res *Item) chromedp.Tasks {
    var urlResult, titleResult string
    return chromedp.Tasks{
        network.Enable(),
        network.SetExtraHTTPHeaders(network.Headers(map[string]any{
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
        })),
        chromedp.Navigate(host),
        chromedp.Sleep(2 * time.Second),                    // 延迟2s等待操作生效
        chromedp.Click(`.dy-account-close`),                // 使用 CSS 选择器点击关闭按钮
        chromedp.EvaluateAsDevTools(`
            var sources = document.getElementsByTagName('source');
            if (sources.length > 0) {
                sources[0].src;
            } else {
                '未找到<source>标签';
            }
        `, &urlResult),                                     // 执行 JavaScript 代码并将结果赋值给变量
        chromedp.Evaluate(`document.title`, &titleResult),  // 执行 JavaScript 代码并将结果赋值给变量
        chromedp.ActionFunc(func(ctx context.Context) error {
            res.id = func(url string) int {
                splits := strings.Split(url, "/")
                last := splits[len(splits)-1]
                num, _ := strconv.Atoi(last)
                return num
            }(host)
            res.url = urlResult
            res.title = titleResult
            return nil
        }),
    }
}

可以看到同步处理速度还是相对较慢的,但想要的内容我们都已经成功拿到了:

保存本地(同步版本)

紧接着,我们就先浅尝一下,请求保存到本地吧:

go 复制代码
func DownloadDouyin(idx int, url, title string) {
    client := &http.Client{}
    req, err := http.NewRequest(http.MethodGet, url, nil)
    if err != nil {
        fmt.Printf("Error creating request for %s: %v\n", url, err)
        return
    }

    ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
    req.Header.Set("User-Agent", ua)

    resp, err := client.Do(req)
    if err != nil {
        fmt.Printf("Error downloading video from %s: %v\n", url, err)
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        fmt.Printf("Error downloading video from %s. Status code: %d\n", url, resp.StatusCode)
        return
    }
    fileName := fmt.Sprintf("/User/mystic/Documents/video/%02d-%s.mp4", idx, title)

    data, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Printf("Error reading response body for %s: %v\n", url, err)
        return
    }

    err = os.WriteFile(fileName, data, 0644)
    if err != nil {
        fmt.Printf("Error writing video to file for %s: %v\n", url, err)
        return
    }

    fmt.Printf("Video %02d downloaded successfully\n", idx)  
}

func main() {
    parseStartAt := time.Now()
    results := ChromeExec()
    parseDuration := time.Since(parseStartAt)
    fmt.Printf("解析总耗时:%s\n", parseDuration)       // 解析总耗时:2m6.314165333s

    downloadStartAt := time.Now()
    for _, item := range results {
        DownloadDouyin(item.id, item.url, item.title)
    }
    downloadDuration := time.Since(downloadStartAt)
    fmt.Printf("下载总耗时:%s\n", downloadDuration)    // 下载总耗时:7m20.820283917s
}

看来已经成功下载到本地了,但是同步的时间实在是太感人了。。。

Goroutine 并发版本

接下来,我们把上面的方式改造成异步协程(Goroutine + Channel)。

每完成一个对 url 的解析,就立马进行文件下载。

go 复制代码
package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"

    "github.com/chromedp/cdproto/network"
    "github.com/chromedp/chromedp"
)

type Item struct {
    id    int
    url   string
    title string
}

const (
    baseURL   = "https://www.douyin.com/collection/7230030857229043749/"
    maxNumber = 5
)

var (
    genURLs = func() []string {
        urls := make([]string, 0, maxNumber)
        for idx := 1; idx <= maxNumber; idx++ {
            url := baseURL + strconv.Itoa(idx)
            urls = append(urls, url)
        }
        return urls
    }
)

func chromeProducer(results chan Item) {
    var wg sync.WaitGroup
    defer close(results) // 在函数结束时关闭通道
    for _, url := range genURLs() {
        wg.Add(1)
        go func(url string) {
            defer wg.Done()
            // 配置选项
            opts := append(chromedp.DefaultExecAllocatorOptions[:],
                chromedp.Flag("headless", true), // 无头模式
                chromedp.Flag("disable-gpu", true),
                chromedp.Flag("no-sandbox", false))

            // 创建新的上下文和取消函数
            ctx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
            defer cancel()

            // 创建一个新的Chrome实例
            ctx, cancel = chromedp.NewContext(ctx)
            defer cancel()
            var res Item
            err := chromedp.Run(ctx,
                chromeActions(url, &res),
            )
            if err != nil {
                fmt.Printf("执行任务失败:%v\n", err)  
            }

            // 将值传入到通道中  
            results <- res
        }(url)  
    }
    wg.Wait()
}

func chromeReceiver(results chan Item) {
    var wg sync.WaitGroup

    for item := range results {
        // 处理接收到的数据
        fmt.Println("Received:", item)

        // 下载视频
        wg.Add(1)
        go func(item Item) {
            defer wg.Done()
            downloadDouyin(item)
        }(item)
    }
    wg.Wait()
    fmt.Println("全部下载完毕!")
}

func worker() {
    var wg sync.WaitGroup
    results := make(chan Item, 5) // 创建一个容量为 5 的信号量通道  

    wg.Add(2)

    go func() {
        defer wg.Done()
        // 接收值
        chromeReceiver(results)
    }()

    go func() {
        defer wg.Done()
        // 向通道发送数据
        chromeProducer(results)
    }()

    wg.Wait()
}

func main() {
    start := time.Now()

    worker()  

    duration := time.Since(start)
    fmt.Printf("总耗时:%s\n", duration)
}

func downloadDouyin(item Item) {
    fmt.Printf("Downloading %v.......", item.title)  

    client := &http.Client{}
    req, err := http.NewRequest(http.MethodGet, item.url, nil)
    if err != nil {
        fmt.Printf("Error creating request for %s: %v\n", item.url, err)  
        return
    }

    ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
    req.Header.Set("User-Agent", ua)

    resp, err := client.Do(req)
    if err != nil {
        fmt.Printf("Error downloading video from %s: %v\n", item.url, err)
        return
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        fmt.Printf("Error downloading video from %s. Status code: %d\n", item.url, resp.StatusCode)
        return
    }
    fileName := fmt.Sprintf("/Users/mystic/Documents/video/%02d-%s.mp4", item.id, item.title)

    data, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Printf("Error reading response body for %s: %v\n", item.url, err)
        return
    }
  
    err = os.WriteFile(fileName, data, 0644)
    if err != nil {
        fmt.Printf("Error writing video to file for %s: %v\n", item.url, err)
        return
    }

    fmt.Printf("Video %02d downloaded successfully\n", item.id)
}

func chromeActions(host string, res *Item) chromedp.Tasks {
    var urlResult, titleResult string
    return chromedp.Tasks{
        network.Enable(),
        network.SetExtraHTTPHeaders(network.Headers(map[string]any{
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36",
        })),
        chromedp.Navigate(host),
        chromedp.Sleep(2 * time.Second),                    // 延迟2s等待操作生效
        chromedp.Click(`.dy-account-close`),                // 使用 CSS 选择器点击关闭按钮
        chromedp.EvaluateAsDevTools(`
            var sources = document.getElementsByTagName('source');
            if (sources.length > 0) {
                sources[0].src;
            } else {
                '未找到<source>标签';
            }
        `, &urlResult),                                     // 执行 JavaScript 代码并将结果赋值给变量
        chromedp.Evaluate(`document.title`, &titleResult),  // 执行 JavaScript 代码并将结果赋值给变量
        chromedp.ActionFunc(func(ctx context.Context) error {
            res.id = func(url string) int {
                splits := strings.Split(url, "/")
                last := splits[len(splits)-1]
                num, _ := strconv.Atoi(last)
                return num
            }(host)
            res.url = urlResult
            res.title = titleResult
            return nil
        }),
    }
}

注意:这个并发版本有一点小问题,chromedp同时启多个会有解析失败的bug,懒得改了,就这样吧。。。

结果对比

同步和异步的整体完成耗时情况对比如下:

同步方式:39个视频:9min30s(2m6s解析+7m20s下载)

异步方式:5个视频:总耗时 24.345688333s

彩蛋

最后,附上华语乐坛 2000-2009 Top1

那么,谁是你心目中的华语 TOP 1 呢?

相关推荐
猪猪拆迁队11 分钟前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库35 分钟前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横35 分钟前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885021 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan1 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885021 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia1 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530141 小时前
Java 实现 Word 文档加密与权限解除
java·后端
vanuan2 小时前
给你的A2A-Agent加把锁-认证鉴权实战指南
后端
Yeats_Liao2 小时前
14:Servlet中的页面跳转-Java Web
java·后端·架构