玩转 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 呢?

相关推荐
骆晨学长26 分钟前
基于springboot的智慧社区微信小程序
java·数据库·spring boot·后端·微信小程序·小程序
AskHarries31 分钟前
利用反射实现动态代理
java·后端·reflect
Flying_Fish_roe1 小时前
Spring Boot-Session管理问题
java·spring boot·后端
hai405872 小时前
Spring Boot中的响应与分层解耦架构
spring boot·后端·架构
Adolf_19933 小时前
Flask-JWT-Extended登录验证, 不用自定义
后端·python·flask
叫我:松哥3 小时前
基于Python flask的医院管理学院,医生能够增加/删除/修改/删除病人的数据信息,有可视化分析
javascript·后端·python·mysql·信息可视化·flask·bootstrap
海里真的有鱼3 小时前
Spring Boot 项目中整合 RabbitMQ,使用死信队列(Dead Letter Exchange, DLX)实现延迟队列功能
开发语言·后端·rabbitmq
工业甲酰苯胺3 小时前
Spring Boot 整合 MyBatis 的详细步骤(两种方式)
spring boot·后端·mybatis
新知图书4 小时前
Rust编程的作用域与所有权
开发语言·后端·rust
wn5315 小时前
【Go - 类型断言】
服务器·开发语言·后端·golang