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

個人的には 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 になったら、終了です。