goqueryでスクレイピングした際の実装メモ

この記事は、[Go4 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/go4) の18日目です。

17日目は [@ikawaka](https://qiita.com/ikawaha) による以下記事でした。お疲れ様でした！

- [人生で何度目かのダブル配列TRIEを書いた](https://qiita.com/ikawaha/items/edb4e18960ae6e4babc3)

# はじめに（本記事の内容）

## 概要

本記事では、
自分がgoqueryを利用して初めてスクレイピングした際の
実装メモをコードベースで解説していきたいと思います。

もし、こんな記事が、
初めてスクレピングされる方や`goquery`を使用される方の
参考になれば幸いです。

今回スクレイピングしたのは、techplayさんのイベントページになります。[^1]
実績としては以下のページとなります。

- 例
    - [執筆の技術を勉強する会 #1](https://techplay.jp/event/700825)
    - [エンジニアの登壇を応援する忘年LT大会
](https://techplay.jp/event/705867)

以下Gistに公開したコードを元に解説してきます。[（解説へジャンプされたい方はこちらをクリック）](#コード解説)

- [Gist - techplay.go](https://gist.github.com/zakizaki-ri9/5b4ec83a39ca554cdb5581311e4597c9)

## 経緯

現在、[エンジニアの登壇を応援する会](https://engineers.connpass.com/)というコミュニティに所属しているのですが、

- 主催・関連イベントの以下情報を定期通知したい
    - なぜ？
        - イベント告知
        - 参加枠等々の調整や準備のため
    - 必要な情報
        - 開催日時
        - 各参加枠の人数
        - 総参加人数
    - イベント告知で利用しているサービス
        - connpass
            - [イベントサーチAPI](https://connpass.com/about/api/)が提供されている（2018/12時点）
        - techplay
            - APIが提供されていない（2018/12時点）
            - <font color="red"><b>スクレイピング</b>しないとあかん</font>

と、ざっくり上記のような需要が発生しました。

ほぼ同時期に、以下の書籍でGo言語は登場しませんが、
Netlifyという大変便利なサービスを知りました。

- [boost - Netlifyで始めるサーバーレス開発](https://booth.pm/ja/items/1044205)

また、Go言語を学び始めてまもなかった自分は「何かテーマを決めてプログラミングしたい」と思い、
<b>Netlify Functions x Go lambdaでスクレイピングしてみよう</b>ということに至りました。

# 本題

今回は「[執筆の技術を勉強する会 #1](https://techplay.jp/event/700825)」のページを題材に解説していきます。
スクレイピングする箇所は以下赤枠の部分です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/140704/570e94bf-4afd-9415-5796-525394438ea6.png)

## コード解説

ではコードを元に解説していきます。

Netlify（もといNetlifyFunctions）は、AWS lambdaと同じ内容で実装可能です。
そのため、`func main`の解説は省かせていただき、
`func handler`の解説から入ります。

### イベントページのDOM取得

以下がリクエスト用のオブジェクトです。

```go
// Request用オブジェクト
type TechplayParam struct {
	EventId int `json:"event_id"`
}
```

techplayのイベントページは`https://techplay.jp/event/%d`のように、
最後の`%d`の値がイベント毎に異なる状態です。（2018/12時点）

そのため、`%d`をイベントIDを見立ててリクエスト情報を渡し、
以下でイベントページを取得しています。

```go
// イベントURL
eventUrl := "https://techplay.jp/event/%d"

// リクエスト情報をパース
requestParam := new(TechplayParam)
if err := json.Unmarshal(([]byte)(request.Body), requestParam); err != nil {
  return events.APIGatewayProxyResponse{}, err
}

// イベントページ取得
doc, err := goquery.NewDocument(fmt.Sprintf(eventUrl, requestParam.EventId))
if err != nil {
  return events.APIGatewayProxyResponse{}, err
}
```

### 値取得「タイトル・日付」

Chromeの開発者ツール等を使い、
タイトルと日付部分のHTMLコードを確認してみると、
以下のように記述されていることが確認できます。

```html
<!-- タイトル部分 -->
<div class="title-heading">
 <h1>【増枠】執筆の技術を勉強する会 #1</h1>
 〜いろんな要素〜
</div>

〜省略〜

<!-- 日付部分 -->
<div class="event-day">2018/10/26(金)</div>
<div class="event-time">19:00〜22:00</div>
```

`goquery`は上記のような要素の「`要素ID`、`要素CLASS`、`要素名`」などを指定し、
値を取得することができます。

先ほど`goquery.NewDocument`を実行した際、
`doc`にDOMの情報が格納されています。

Gistでは、以下のように`doc`からタイトルと日付の値を取得しています。

```go
// タイトル部分抜き出し
//  root: <div class="title-heading">
techplayEvent.Title = doc.Find("div.title-heading > h1").Text()

// 日付部分抜き出し
//  root: <div class="event-day">
techplayEvent.Day = doc.Find("div.event-day").Text()

// 時間部分抜き出し
//  root: <div class="event-time">
techplayEvent.Time = doc.Find("div.event-time").Text()
```

タイトル取得のロジックを見ていただくとわかりやすいのですが、
`goquery`は`div.title-heading > h1`のように、
要素を深掘りつつ、取得することが可能です。
（とても便利ですね。）

### 値取得「参加枠・定員」

参加枠の部分は、少し入り組んでいます。
<b>スタッフ枠</b>の行を例にコード見てみると、以下のように記述されています。

```html
<div id="participationTable">
 <table>
  〜ヘッダ部分は省略〜
   <tr>
    <td　class="category">
　　　　　　　　 <div class="category-inner">
　　　　　　　　  <div>スタッフ枠</div>
　　　　　　　　 </div>
　　　　　　　　</td>
　　　　　　　　〜他要素は省略〜
    <td　class="capacity">
　　　　　　　　 <span class="num">
6人
　　　　　　　　 </span>
／ 定員7人
　　　　　　　　</td>
   </tr>
 </table>
</div>
```

<b>参加枠（スタッフ枠）</b>は先ほどのように要素を深掘って値を取得すれば良いですが、
<b>定員</b>は少し不要な要素を削って値を取得する必要があります。

また、`<table>`内は複数行(複数の`<tr>`）あるため、
行数分繰り返し＆値取得する必要があります。

Gistでは、以下のように実装しています。

```go
// 参加枠、定員抜き出し
//  root: <div id="participationTable">
tableSelection := doc.Find("div#participationTable > table > tbody > tr")
reSpan := regexp.MustCompile(`<.span.>`)
tableSelection.Each(func(_ int, s *goquery.Selection) {
  d := Detail{Category: s.Find("td.category > div.category-inner > div").Text()}
  d.Capacity = getCapacity(reSpan, s.Find("td.capacity").Text())
  techplayEvent.DetailList = append(techplayEvent.DetailList, &d)
})

// データクレンジングをしながら定員のみの文字列を取得する
func getCapacity(r *regexp.Regexp, target string) string {
  result := strings.Replace(target, "\n", "", -1)
  result = strings.Replace(result, " ", "", -1)
  result = strings.Replace(result, "定員", "", -1)
  result = r.ReplaceAllString(result, "")
  result = strings.Replace(result, "／", "/", -1)

  // connpass-apiと同じ表記にするために最初の"人"を除外
  if idx := strings.Index(result, "/"); 0 < idx {
    result = strings.Replace(result[:idx], "人", "", 1) + result[idx:]
  }

  return result
}
```

`tableSelection := doc.Find`で`<tr>`の部分を取得し、
`tableSelection.Each`で行数分の繰り返し処理を行なっています。

<b>参加枠（スタッフ枠）</b>は、
`td.category > div.category-inner > div`で要素を深掘って値取得。

<b>定員</b>は、
`Text()`で`<td>`内の要素を除いた値のみを取得し、
`func getCapacity`にて、正規表現(`regexp`)や文字列操作(`strings`)で
不要な文字を除去して値を取得しています。

```go
// 合計人数を取得する
func getTotal(dArr Details) string {
  total := 0
  for _, d := range dArr {
    if num, result := capacity2JoinNum(d.Capacity); result {
      log.Printf("%s, %d¥n", d.Capacity, num)
      total += num
    }
  }
  return fmt.Sprintf("%d人", total)
}
```

### 値取得「合計人数」

最後に補欠人数分を除いた合計人数を以下コードで取得しています。

```go
// 合計人数取得
techplayEvent.Total = getTotal(techplayEvent.DetailList)

// 合計人数を取得する
func getTotal(dArr Details) string {
  total := 0
  for _, d := range dArr {
    if num, result := capacity2JoinNum(d.Capacity); result {
      log.Printf("%s, %d¥n", d.Capacity, num)
      total += num
    }
  }
  return fmt.Sprintf("%d人", total)
}

// Detail.Capacityから参加人数のみ取得
func capacity2JoinNum(target string) (int, bool) {
  if idx := strings.Index(target, "/"); 0 < idx {
    mol := 0
    if num, err := strconv.Atoi(target[0:idx]); err == nil {
      mol = num
    }
    if idxDen := strings.Index(target, "人"); 0 < idxDen {
      if num, err := strconv.Atoi(target[idx+1 : idxDen]); err == nil && num < mol {
        mol = num
      }
    }
    return mol, true
  } else if idx := strings.Index(target, "人"); 0 < idx {
    if num, err := strconv.Atoi(target[0:idx]); err == nil {
      return num, true
    }
  }
  return -1, false
}
```

定員部分の要素は`○/△人`というフォーマットとなっています。

`func capacity2JoinNum`では、`○/△人`の`○`のみを取得。
`○`が定員分を超過している場合は、`△`（最大人数）を返すようにしています。

## 結果

結果、GistのコードをNetlify Functionsへデプロイ。
実行すると、以下のような結果が得られます。

```json
{
  "total": "71人",
  "detail_list": [{
      "category": "一般参加枠",
      "capacity": "53/61人"
    },
    {
      "category": "日本酒枠（当日持込）",
      "capacity": "6/6人"
    }, {
      "category": "スタッフ枠",
      "capacity": "6/7人"
    }, {
      "category": "スピーカー枠（事前連絡済）",
      "capacity": "6/6人"
    }
  ],
  "time": "19:00〜22:00",
  "title": "【増枠】執筆の技術を勉強する会 #1",
  "day": "2018/10/26(金)",
  "event_url": "https://techplay.jp/event/700825"
}
```

あとは、この結果を呼び出し元のコード等でよしなにすれば、
Slackやチャットツールの通知等に活かせるかなと思います。

# おわり

解説は以上になります。

実装した感想としては...

- スクレピング楽しい！
- Go言語よき！てかコード補完すごい。

Goとしての実装方法やスクレピングとしてもっといいやり方等あれば、
コメント等でアドバイスいただけると助かります！

---

[^1]: techplayさんの運営に問い合わせして、許可をいただいてスクレピングさせていただいています。念のための周知となりますが、定期的にスクレピングされる際は、スクレイピング先の企業様へご確認・許可いただくようお願いします。m(_ _)m