Node.jsのスクレイピングモジュール「cheerio-httpcli」が第3形態に進化したようです

約1年ぶりになるcheerio-httpcli大きめのバージョンアップ(`0.6.0`)のお知らせです。今回も技術的特異点(言い過ぎ)となる新機能を取り揃えております。

タイトルの通り、cheerio-httpcliはNode.jsによるWEBページのスクレイピングをjQueryライクな操作でサクサク簡単にできるようにするためのモジュールです。

具体的にはこんな感じです(いつもの)。

```js:Google検索
var client = require('cheerio-httpcli');

// Googleで「node.js」について検索する。
client.fetch('http://www.google.com/search', { q: 'node.js' }, function (err, $, res) {
    // レスポンスヘッダを参照
    console.log(res.headers);

    // HTMLタイトルを表示
    console.log($('title').text());

    // リンク一覧を表示
    $('a').each(function (idx) {
        console.log($(this).attr('href'));
    });
});
```

なお、静的ページに対するスクレイピングモジュールなので、SPAのようなクライアントサイドでJavaScriptがグリグリ動いてコンテンツを動的に書き換えていくページには対応しておりません。ご注意を。

本記事は新バージョンで追加された機能の説明が中心になりますので、この記事を見て興味がわいた方は過去の記事もご覧いただければこれ幸いです。

* [【第1形態】Node.js用のスクレイピングモジュール「cheerio-httpcli」の紹介](http://qiita.com/ktty1220/items/e9e42247ede476d04ce2)
* [【第2形態】Node.jsのスクレイピングモジュール「cheerio-httpcli」が大規模アップデートして帰ってきた](http://qiita.com/ktty1220/items/64168e8d416d6d8ffb45)

今回は3回目の記事なので第3形態となります。~~ちなみに後108回の変身を残しています。~~

ではさっそく新機能の紹介行ってみましょう。

## 【新機能1】画像のダウンロード

cheerioオブジェクトから画像のダウンロードができるようになりました。

### 使用方法

こんな感じです。

```js:画像のダウンロード
var fs = require('fs');
var client = require('cheerio-httpcli');

// ①ダウンロードマネージャーの設定(全ダウンロードイベントがここで処理される)
client.download
.on('ready', function (stream) {
    stream.pipe(fs.createWriteStream('/path/to/image.png'));
    console.log(stream.url.href + 'をダウンロードしました');
})
.on('error', function (err) {
    console.error(err.url + 'をダウンロードできませんでした: ' + err.message);
})
.on('end', function () {
    console.log('ダウンロードが完了しました');
});

// ④並列ダウンロード制限の設定
client.download.parallel = 4;

// ②スクレイピング開始
client.fetch('http://foo.bar.baz/', function (err, $, res, body) {
    // ③class="thumbnail"の画像を全部ダウンロード
    $('img.thumbnail').download();
    console.log('OK!');
});
```

①の`client.download`というのがcheerio-httpcliに内蔵されているダウンロードマネージャーになります。

スクレイピング中に`$(...).download()`メソッドで実行された画像のダウンロードが始まると`client.download`の`ready`イベントが発生します(エラーが発生した場合は`error`イベント)。

`end`イベントはダウンロード待ちのURLがなくなった時に発生します。

①では色々な場所から実行される画像ダウンロード時の共通処理を設定しています。この例では引数に渡されたダウンロード元画像ファイルのStreamを`pipe()`で`/path/to/image.png`に保存しています。

※複数の画像をダウンロードした時も全部同名ファイルで上書きする残念な処理ですがサンプルなのでご容赦を。

`client.download`のイベント処理設定が完了したら②スクレイピングに入ります。

②でWEBページを取得し、その中の③で`$(...).download()`メソッドを実行しています。

この時、`$('img.thumbnail')`に該当する画像要素が10個あったとすると、その10個の画像要素がまとめてダウンロードマネージャーに登録されます(すでに登録済みのURLは除外されます)。

少し戻って④を見ると並列ダウンロード数制限が設定されています。今回の例では`4`なので、登録された10個の画像要素の内、即座に4つがダウンロード処理に入ります。

残りの6要素はダウンロード待ちキューに入り、最初の4つの内のどこかのダウンロードが完了して空きができると、次の画像URLがその空き部分にに登録されてダウンロードが実行される ... という流れです。

### 痒いところに手が届くおまけ機能付き

#### LazyLoad画像対応

取得対象のWEBページでLazyLoad系のjQueryプラグインなどを使っている場合は`src`属性にダミーの画像URLが入っていたりすると思いますが、そんなときもちゃんと`data-original`や`data-lazy-src`で指定されている本当の画像をダウンロードしてくれます(パラメータで変更可能)。

#### Base64埋め込み画像対応

`<img src="data:image/png;base64,/9j/4AAQSkZJRgABA ...">`といった埋め込み画像もバイナリ化してダウンロードできます。

#### ダウンロードマネージャーの処理状況が分かる

`$(...).download()`で登録したURLの画像がどれくらいダウンロードできたか、あとどれくらい残っているのかが分かります。

```js
client.download
.on('ready', function (stream) {
    console.log(this.state); // => { queue: 2, complete: 5, error: 1 }
```

#### Streamでいろいろ

Streamにはいくつかのプロパティとメソッドを実装してあります。

1. Bufferに変換

    Streamよりも慣れ親しんだBufferで処理したいあなたのために。

    ```js
    client.download
    .on('ready', function (stream) {
        stream.toBuffer(function (err, buffer) {
            fs.writeFileSync('hoge.jpg', buffer, 'binary');
        });
    ```

2. 各種情報満載

    このStreamの元となる画像ファイルの情報を見ることができます。

    ```js
    client.download
    .on('ready', function (stream) {
        console.log(stream.url.href); // => 'http://hogehoge.com/foobar.png'
        console.log(stream.type);     // => 'image/png'
        console.log(stream.length);   // => 10240
    ```

### 使用上の注意点

1. 画像ダウンロードは本線である②③のスクレイピングとは非同期で行われます。使用方法に挙げたサンプルの場合、③を実行して「OK!」が表示された段階で本線のスクレイピングは終わりますが、画像のダウンロードはまだ途中であり、また、ダウンロードマネージャーに登録した全画像のダウンロードが完了するまではこのスクリプト自体は終了しません。「OK!」が表示されてもなかなかコンソールに制御が戻ってこないからといって`Ctrl+C`とかはせずに、ダウンロード完了までお待ちください。

2. `client.download.parallel`の設定は初期値で`3`、最大で`5`になります。一度に大量の画像をダウンロードすると取得元サーバーへの過度な負荷がかかりますので、ここの数値は控えめにお願いします。なお、cheerio-httpcli自体に手を付ければ無理矢理`100`や`1000`に設定することも可能ですが、そういったヒャッハーな行為はご遠慮ください。

3. `ready`イベント内でストリームを読み込まずに処理を抜ける場合などは必ず`stream.end()`を呼び出してください。そのままにしておくとキューが詰まって次のダウンロードができなくなることがあります。また、ストリームが読み込まれずに放置されたまま`client.timeout`時間が経過すると`error`イベントが発生して強制的にエラー扱いとなります。

## 【新機能2】同期リクエスト

通常、非同期で実行される`fetch()`、`$(...).click()`、`$(...).submit()`を同期で実行(リクエストが完了するまで次の行に進まない)できるメソッドを追加しました。`fs.readFile()`に対する`fs.readFileSync()`の関係と同じような意味合いになります。

非同期リクエスト内で同期リクエストするといったことも可能です。

### fetchSync

`fetch()`の同期版です。戻り値にリクエスト結果が連想配列で入ります。

```js:fetchSync()例
var client = require('cheerio-httpcli');

var result1 = client.fetchSync('http://foo.bar.baz/');
console.log(result1); // => {
                      //        error: ...,
                      //        $: ...,
                      //        response: ...,
                      //        body: ...
                      //    }

console.log(result1.$('title')); // => http://foo.bar.baz/のタイトルが表示される

var result2 = client.fetchSync('http://hoge.fuga.piyo/');
console.log(result2.$('title')); // => http://hoge.fuga.piyo/のタイトルが表示される
```

### clickSync

`$(...).click()`の同期版です。戻り値にリクエスト結果が連想配列で入ります。

```js:clickSync()例
var client = require('cheerio-httpcli');

// fetch()は非同期で行ってその中で同期リクエストする場合
client.fetch('http://foo.bar.baz/', function (err, $, res, body) {
  var result = $('a#login').clickSync();
  console.log(result); // => {
                       //        error: ...,
                       //        $: ...,
                       //        response: ...,
                       //        body: ...
                       //    }
});
```

### submitSync

`$(...).submit()`の同期版です。戻り値にリクエスト結果が連想配列で入ります。

```js:submitSync()例
var client = require('cheerio-httpcli');

// トップページにアクセス(ここも同期リクエストにすることも可能)
client.fetch('http://foo.bar.baz/', function (err, $, res, body) {
    // 同期リクエストでログインページに移動
    var result1 = $('a#login').clickSync();
    // 同期リクエストでログインフォーム送信
    var result2 = result1.$('form[name=login]').submitSync({
        account: 'guest',
        password: 'guest'
    });
    // ログイン結果確認
    console.log(result2.response.statusCode);
});
```

### 使用上の注意点

* 同期リクエストは、外部スクリプトをspawnSync()で実行して処理が完了するまで待つ、という形で実装しているのでパフォーマンスは非常に悪いです(非同期リクエストの10倍程度は時間がかかります)。したがって、実装しておいてなんですが、基本は非同期リクエストで処理を行い、どうしてもここだけは同期リクエストにしたいといった場合のみ、という使い方をお勧めします。

* 同期リクエストの戻り値内のレスポンスは`response.toJSON()`されたものなので非同期版とは内容が若干異なります。`statusCode`や`headers`、`request`などの主要プロパティは共通して使用できるので特に大きな問題はないかと思いますが、特殊な使い方をする場合には注意が必要です。

## 【新機能3】完全な形のURLを取得

```html
<a id="top" href="../index.html">トップページ</a>
```

上記のようなリンクの場合、`$('a').attr('href')`とすると`../index.html`が返ってきますが、ここから完全なURLにするという手間を省くことができるメソッドを追加しました。

先ほどのリンクの完全な形のURLを取得したい場合は以下のようにします。

```js
console.log($('a#top').url()); // => http://foo.bar.baz/index.html
```

`url()`は`a`要素および`img`要素で使用できます。

## 【新機能4】フォーム要素の操作拡張

### チェックボックスやラジオボタンのON/OFFをエミュレート

`$(...).tick()`と`$(...).untick()`というメソッドを追加しました。チェックボックスとラジオボタン要素で使用できます。

```html
<input type="checkbox" name="check_ok" value="OK!">
```

普通にcheerioの標準機能で上記のようなチェックボックスの状態を変更する場合は、

```js
$('input[name=check_ok]').attr('checked', 'checked');
$('input[name=check_ok]').removeAttr('checked');
```

みたいな感じになりますが、cheerio-httpcli上では以下のように書けます。

```js
$('input[name=check_ok]').tick();
$('input[name=check_ok]').untick();
```

別に`tick()`とか使わんでも`submit()`の時にパラメータ指定すればええやん、と思われるかもしれません。確かに以下のようにすればチェックしたのと同じ状態でフォーム送信できます。

```js
$('form').submit({
    check_ok: 'OK!'
}, function (err, $, res, body) {
  .
  .
  .
```

しかし、地域選択とかジャンル選択とかの検索ページなどでチェックボックスが50個とか100個あった場合はさすがに`submit()`時に指定するのは面倒です。そんな時も`tick()`を使えばこんな感じにシンプルに書くことができます。

```js
// 全部のチェックボックスを選択した状態にしてフォーム送信
$('input[type=checkbox]').tick();
$('from').submit();
```

### フォーム内の部品の状態を個別または一括で取得/設定

jQueryには`$(...).attr()`や`$(...).css()`など、属性やスタイルを取得したり設定するメソッドがありますが、それと同じ感覚でフォーム内の部品の状態を取得したり設定できるメソッド`$(...).field()`を追加しました。

```html
<form name="login">
    <input type="text" name="user" value="guest">
    <input type="password" name="pass" value="">
    <input type="checkbox" name="remember" value="1">
    <input type="submit">
</form>
```

上記のHTMLに対して`$(...).field()`で以下のようなことができます。

```js
// userのvalueを取得
$('form[name=login]').field('user'); // => guest

// passのvalueを設定
$('form[name=login]').field('pass', 'admin');

// 一括で設定
$('form[name=login]').field({
    user: 'foo',
    pass: 'bar'
});

// 一括で取得
$('form[name=login]').field(); // => {
                               //        user: 'foo',
                               //        pass: 'bar',
                               //        remember: 1
                               //    }
```

## 【新機能5】より本物のブラウザに近い挙動に

### リクエストヘッダの自動補完

Accept-Languageヘッダを指定していない場合は実行環境のロケールから言語を取得して自動でセットするようになりました(WindowsかつNode.js v0.10以下の環境では未対応)。

また、Acceptヘッダを指定していない場合は一般的なブラウザのAcceptヘッダをセットするようにしました。

### フォーム送信時の挙動の変更

ある意味`0.3.x`と互角性がなくなる変更になりますが、`$(...).submit()`時にsubmit系ボタンの情報を送信パラメータにセットしないように変更しました。

どういうことか実例で説明すると、

```html
<form>
  <input type="text" name="user" value="guest">
  <input type="submit" name="edit" value="edit">
  <input type="submit" name="delete" value="delete">
</form>
```

上記フォームは1フォーム内に複数の`submit`ボタンがあります。おそらくサーバー側では押されたボタンの`name`や`value`で処理を分岐させているのだと思います。

しかし、`0.3.x`のcheerio-httpcliはフォーム内の全パラメータを送信していたので、こういったフォームを`$(...).submit()`した場合、正常な結果を得ることができないケースがありました。

```js:cheerio-httpcli@0.3.x
$('form').submit(); // => ?user=guest&edit=edit&delete=delete
```

対して、ブラウザ上でjQueryを使用して`$(...).submit()`とすると、`submit`系要素のパラメータは送信していないことが分かりました(もしかしたら常識だったのかもしれないけど)。

```js:jQuery
$('form').submit(); // => ?user=guest
```

なので`0.6.0`からはブラウザに合わせて`$(...).submit()`時に`submit`系要素のパラメータは送信しないようにしました。

その代わりとして、`submit`系要素に`$(...).click()`メソッドを追加しました。`$(...).click()`メソッドを使用すれば押したボタンのパラメータが付加されてフォーム送信されます。

```js:cheerio-httpcli@0.6.0
$('input[name=delete]').click(); // => ?user=guest&delete=delete
```

正しい挙動になる修正とは言え、`0.3.x`とは互換性がなくなる動作なので、今までたまたま動いていたケースがうまく動かなくなるケースもあります。その点ご注意ください。

## まとめ

今回追加した新機能のメソッドの引数やオプションの説明などのドキュメント、その他サンプルスクリプトについては[GitHubのリポジトリ](https://github.com/ktty1220/cheerio-httpcli)にまとめてありますで、そちらをご覧いただければと思います。

また、バグやナイスアイディアなどありましたら[Issues](https://github.com/ktty1220/cheerio-httpcli/issues)や[PR](https://github.com/ktty1220/cheerio-httpcli/pulls)でお知らせいただければありがたいです。

それでは素敵なスクレイピングライフを。