最近、会社で非同期処理が辛い的な話をよくします。

個人的には Go を推しているので、せっかくだから簡単なクローラーを Go で書いてみました。

作ったやつ

  • 起点となるURLを指定する。

  • 取得した HTML の a タグで指定してあるURLを次に参照する。

  • 同じURLは一度だけ参照。

  • Webページの取得は非同期に。

  • クロールする深さを指定できる

解説

goroutine の制御

基本的に、メインの goroutine (main 関数) と、Webページにアクセスしてスクレイピングする goroutine (Crowl 関数) の2種類の goroutine でやり取りを行います。

goroutine の間は、3種類のチャンネルを使用して連携しています。

リクエスト

リクエストは、リクエストチャンネルに追加します。

メインの goroutine でリクエストをチャンネルから取り出し、Crawl 関数を goroutine として実行しています。

この時、クロールの深さと URL を参照したかどうかのチェックを行っています。

クロール処理

func Crawl(url string, depth int, ch *Channels) {
    defer func() { ch.quit <- 0 }()

    // WebページからURLを取得
    urls, err := Fetch(url)

    // 結果送信
    ch.res <- Result{
        url: url,
        err: err,
    }

    if err == nil {
        for _, url := range urls {
            // 新しいリクエスト送信
            ch.req <- Request{
                url:   url,
                depth: depth - 1,
            }
        }
    }
}

まず、指定されたURLのWebページを取得し、その中のURLを取得します。

取得した結果(成功 or エラー)を、結果用のチャンネルに追加します。

また、Webページ内のURLが取得出来た場合は、新しいリクエストとしてそれぞれチャンネルに追加します。 ここで、クロールの深さをデクリメントします。

終了時に、処理終了の通知をチャンネルに追加します。

Web ページの取得とスクレイピング

func Fetch(u string) (urls []string, err error) {
    baseUrl, err := url.Parse(u)
    if err != nil {
        return
    }

    resp, err := http.Get(baseUrl.String())
    if err != nil {
        return
    }
    defer resp.Body.Close()

    doc, err := goquery.NewDocumentFromReader(resp.Body)
    if err != nil {
        return
    }

    urls = make([]string, 0)
    doc.Find("a").Each(func(_ int, s *goquery.Selection) {
        href, exists := s.Attr("href")
        if exists {
            reqUrl, err := baseUrl.Parse(href)
            if err == nil {
                urls = append(urls, reqUrl.String())
            }
        }
    })

    return
}

スクレイピングは、goquery を使ってみました。jQuery 使うような感覚で簡単に要素を抽出できて便利です。

チャンネルの制御

リクエスト、結果、終了通知の3つのチャンネルは、メインの goroutine で制御しています。

   // ワーカーの数
    wc := 0

    done := false
    for !done {
        select {
        case res := <-chs.res:
            if res.err == nil {
                fmt.Printf("Success %s\n", res.url)
            } else {
                fmt.Fprintf(os.Stderr, "Error %s\n%v\n", res.url, res.err)
            }
        case req := <-chs.req:
            if req.depth == 0 {
                break
            }

            if urlMap[req.url] {
                // 取得済み
                break
            }
            urlMap[req.url] = true

            wc++
            go Crawl(req.url, req.depth, chs)
        case <-chs.quit:
            wc--
            if wc == 0 {
                done = true
            }
        }
    }

また、リクエストを取得して goroutine を実行する際にワーカー数をインクリメント、結果通知を受け取った場合にワーカー数をデクリメントしています。

ワーカー数が 0 になったら、終了です。