スクレイピングする Hubot スクリプトをつくろう

これは [Hubot Advent Calendar 2014][hubot-adventar-2014] の 9 日目の記事です。

昨日は @udzura さんの 『[carrierモジュールで雑にHubotからコマンドを打つ][hubot-adventar-2014-8]』でした。stream を中心としたプログラミングは Node.js らしい良い形なのですが、ぼく個人としてはあんまり使えてないですね。あと @bouzuya Advent Calendar 的な煽りが見えた気もしますが、気のせいでしょう。

また、今回は @bouzuya の Hubot 連載の第 7 回です。[目次は、第 1 回の記事にある][hubot-adventar-2014-bouzuya-index]ので、そちらをどうぞ。

# 前回まで、そして今回は

前回は [簡単な Hubot スクリプトをもっとつくろう (おみくじ系)][hubot-adventar-2014-7] ということで、`Response#random` を使って、ランダムにひとつを選んで返す Hubot スクリプトをつくりました。

今回は外部の npm パッケージを使いながらスクレイピングする Hubot スクリプトをつくりましょう。npm パッケージを自由に使えるようになれば、できることがさらに広がります。

# スクリプトの完成イメージ

まず、今回つくろうとしている Hubot スクリプトのイメージを共有しましょう。

今回は「スクレイピング」ということで、[Hubot Advent Calendar 2014][hubot-adventar-2014] でも利用している「 [Adventar][adventar] 」に HTTP リクエストをして、その結果を解析しましょう。

実行イメージはこんな感じです。

```
bouzuya> hubot adventar hubot
hubot> Hubot http://www.adventar.org/calendars/384
```

# さっそくつくる

まずは Hubot スクリプトのテンプレートを `generator-hubot` で生成します。

```bash
$ mkdir hubot-adventar
$ cd hubot-adventar

$ yo hubot:script
...
```

もう慣れたものだと思います。

次に Node.js のスクレイピングでの定番パッケージである `request` と `cheerio` を使いましょう。以下のコマンドで npm リポジトリからそれらのパッケージをダウンロードし、`package.json` に依存関係を追加できます。 

```bash
$ npm install --save request cheerio
request@2.49.0 node_modules/request
├── caseless@0.8.0
├── json-stringify-safe@5.0.0
├── forever-agent@0.5.2
├── aws-sign2@0.5.0
├── stringstream@0.0.4
├── tunnel-agent@0.4.0
├── oauth-sign@0.5.0
├── node-uuid@1.4.2
├── qs@2.3.3
├── mime-types@1.0.2
├── combined-stream@0.0.7 (delayed-stream@0.0.5)
├── form-data@0.1.4 (mime@1.2.11, async@0.9.0)
├── tough-cookie@0.12.1 (punycode@1.3.2)
├── http-signature@0.10.0 (assert-plus@0.1.2, asn1@0.1.11, ctype@0.5.2)
├── bl@0.9.3 (readable-stream@1.0.33)
└── hawk@1.1.1 (cryptiles@0.2.2, sntp@0.2.4, boom@0.4.2, hoek@0.9.1)

cheerio@0.18.0 node_modules/cheerio
├── entities@1.1.1
├── lodash@2.4.1
├── dom-serializer@0.0.1 (domelementtype@1.1.3)
├── htmlparser2@3.8.2 (domelementtype@1.1.3, domutils@1.5.0, entities@1.0.0, domhandler@2.3.0, readable-stream@1.1.13)
└── CSSselect@0.4.1 (domutils@1.4.3, CSSwhat@0.4.7)
```

簡単ですね。npm はこのように簡単にパッケージを追加できます。`dependencies` もきちんとソートしてくれます。

さて、書いていきましょう。では、こちらに完成したものがございます。

```coffeescript:src/adventar.coffee
# Description
#   A Hubot script for listing advent calendars in Adventar
#
# Configuration:
#   None
#
# Commands:
#   hubot adventar <query> - lists advent calendars in Adventar
#
# Author:
#   bouzuya <m@bouzuya.net>

cheerio = require 'cheerio'
request = require 'request'

module.exports = (robot) ->

  robot.respond /adventar(?: (\S+))?/, (msg) ->
    query = msg.match[1]

    # send HTTP request
    baseUrl = 'http://www.adventar.org'
    request baseUrl + '/', (_, res) ->

      # parse response body
      $ = cheerio.load res.body
      calendars = []
      $('.mod-calendarList .mod-calendarList-title a').each ->
        a = $ @
        url = baseUrl + a.attr('href')
        name = a.text()
        calendars.push { url, name }

      # filter calendars
      filtered = calendars.filter (c) ->
        if query? then c.name.match(new RegExp(query, 'i')) else true

      # format calendars
      message = filtered
        .map (c) ->
          "#{c.name} #{c.url}"
        .join '\n'

      msg.send message
```

# 解説

ええと、解説します。

## `require`

```coffeescript
cheerio = require 'cheerio'
request = require 'request'
```

`cheerio` / `request` という変数にそれぞれ npm パッケージを読み込んでいます。使いかたの詳細はそれぞれのリポジトリを見てください。

## `(?: (\S+))?`

```coffeescript
robot.respond /adventar(?: (\S+))?/, (msg) ->
```

Hubot スクリプトに限った話じゃないけど、個人的にこの `(?: (\S+))?` や `(?:\s+(\S+))?` のような正規表現をよく使います。`(?:)` はマッチしても取り出しの際のグループには追加されません。

## `request`

```coffeescript
# send HTTP request
baseUrl = 'http://www.adventar.org'
request baseUrl + '/', (_, res) ->
```

素朴に HTTP リクエスト + コールバックの設定。Node.js での基本形です。今回は手抜きのためにコールバックの第一引数の `err` を無視しています。

## `cheerio`

```coffeescript
# parse response body
$ = cheerio.load res.body
calendars = []
$('.mod-calendarList .mod-calendarList-title a').each ->
  a = $ @
  url = baseUrl + a.attr('href')
  name = a.text()
  calendars.push { url, name }
```

`cheerio` を使うと jQuery のようなセレクタなどが使える。ぼくは基本的には上記のように情報を抜き出したらつめかえて、それ以降は `cheerio` を使わない主義です。`cheerio` でメソッドチェインするとわかりづらいので。

あとは特にないかな。普通の JavaScript な動きだし。いつもなら面倒なので全部チェインにしているけど、コメントいれたほうがわかりやすいかと思って、区切ってみました。

# 動かしてみる

じゃあ、動かしてみましょう。

```bash
$ HUBOT_SHELL_USER_NAME='bouzuya' PATH="./node_modules/hubot/node_modules/.bin:$PATH" $(npm bin)/hubot -a shell -n hubot -r src
hubot> hubot adventar hubot
hubot> Hubot http://www.adventar.org/calendars/384
hubot> hubot adventar
...
いろふ http://www.adventar.org/calendars/331
蒙古タンメン中本 http://www.adventar.org/calendars/330
ラーメン http://www.adventar.org/calendars/329
プリキュア http://www.adventar.org/calendars/328
しょぼちむ http://www.adventar.org/calendars/327
hubot> 
```

よいのでは

# まとめ

スクレイピングをする Hubot スクリプトをつくってみました。そんなに細かく説明していませんが、たぶん大丈夫でしょう。もうぼくの知っていることはほぼ書いたと言って良いですね。

ちなみに今回のサンプルは [bouzuya/hubot-adventar][] にあります。うまく動かない、などがあれば参考にどうぞ。

# 最後に

こういう場面で紹介するのにちょうど良い API って何なのでしょう。できればトークンなどが不要で、それなりに知名度があって、可能なら画像を返してくれると Slack などで見ると非常に良いのですが。

次回どうしましょうか。Hubot で HTTP を待ち受ける話でもしましょうかね。

では。

[getting-started-with-hubot]: https://github.com/github/hubot/tree/v2.9.3/docs#getting-started-with-hubot
[bouzuya/hubot-hello-world]: https://github.com/bouzuya/hubot-hello-world/
[bouzuya/hubot-choice]: https://github.com/bouzuya/hubot-choice/
[bouzuya/hubot-adventar]: https://github.com/bouzuya/hubot-adventar/
[github/hubot]: https://github.com/github/hubot/
[hubot.github.com]: https://hubot.github.com/
[hubot-adventar-2014]: http://www.adventar.org/calendars/384
[hubot-adventar-2014-bouzuya-index]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-1]: http://qiita.com/bouzuya/items/c7d0ad80c357aab6b696
[hubot-adventar-2014-2]: http://qiita.com/bouzuya/items/11c0c6da2b3ad54b827f
[hubot-adventar-2014-3]: http://qiita.com/bouzuya/items/2a200c9e8a45e2478bc2
[hubot-adventar-2014-4]: http://qiita.com/bouzuya/items/4c0206d72ff22ade9339
[hubot-adventar-2014-5]: http://pasero.net/~mako/blog/s/685
[hubot-adventar-2014-6]: http://qiita.com/bouzuya/items/4e051f68f8d68a2944b0
[hubot-adventar-2014-7]: http://qiita.com/bouzuya/items/2e935c99647b93d8ee1d
[hubot-adventar-2014-8]: http://udzura.hatenablog.jp/entry/2014/12/08/182744
[ops-for-everyone]: https://speakerdeck.com/johndbritton/ops-for-everyone
[adventar]: http://www.adventar.org/