Node.js でお手軽スクレイピング 2020 年夏

皆さんは Web ページのスクレイピングって書いた事ありますか？私はあります。だってどんなに平和で平穏な生活を送っていても数年に一度はスクレイピングってしたくなりますよね。「うわーまじか！API ないのかよ…。」的な。

そうしたら HTTP クライアントと HTML パーサのライブラリを探してきてインストールした上でごりごり書くことになると思います。でも実際に書いてみると、そうやってライブラリのインストールをしたりサンプルコードで動作確認している時間よりも、HTML を解析して実際にパースしたところから対象の要素を取得して欲しい値を取り出す試行錯誤の時間の方が長かったっていう事はないですか？

今日ご紹介する Node.js でお手軽スクレイピングは、その辺の試行錯誤の手間を極力減らすことが出来る方法です。2020 年夏の最新版です。

まずは環境から。特に古いものを使う理由もないので 2020-07-20 時点の最新版 `14.5.0` を使っています。

```bash
$ node -v
v14.5.0
```

そしてプロジェクトの初期化を行って、2 つほどライブラリをインストールします。

```bash
$ npm init
$ npm install node-fetch jsdom --save-dev
```

[node-fetch](https://github.com/node-fetch/node-fetch) は Node.js 上でウェブブラウザと同じような `fetch` を使えるようにするライブラリです。普段 Web ベースの JS を書いてると、HTTP アクセスするにも `fetch` が直感的で楽だなーと思うので選びました。GitHub 上のスターは 5.3k。素晴らしいですね。

[jsdom](https://github.com/jsdom/jsdom) はウェブブラウザと同様の API セットを持った HTML DOM ツリーをメモリ上に構築することが出来るライブラリです。Pure JavaScript で実装されたウェブブラウザのサブセットと思うと理解しやすいかも知れません。GitHub 上のスターは 14.4k。今回の記事の要です。

必要なライブラリが揃ったところで早速スクリプトを書いていきましょう。サンプルに[気象庁の東京都の週間天気予報](https://www.jma.go.jp/jp/week/319.html)のページを選びました。

```js:index.mjs
#!/usr/bin/env node

import fetch from 'node-fetch';
import jsdom from 'jsdom';

const { JSDOM } = jsdom;

(async () => {
    const res = await fetch('https://www.jma.go.jp/jp/week/319.html');
    const html = await res.text();
    const dom = new JSDOM(html);
    const document = dom.window.document;
    const nodes = document.querySelectorAll('#infotablefont tr:nth-child(4) td');
    const tokyoWeathers = Array.from(nodes, td => td.textContent.trim());
    console.log(tokyoWeathers);
})();
```

これだけ見て「あー、なるほど！」ってならない方のために詳細な解説は後ほど加えていきますが、まず一番のポイントは `const nodes` から始まる行以降です。お気づきでしょうか？この行以降はそのままウェブブラウザ上でも実行可能なことに。

従来のスクレイピングでは、必要な DOM 要素を取得するためのクエリを探したり、得られたノードを加工して必要なリストに変換する試行錯誤に時間がかかっていました。その試行錯誤自体を無くすことは不可能ですが、ウェブブラウザ上のデベロッパーツールであれば、リアルタイムに結果を見ながら試行錯誤することでその手間を大幅に減らすことが出来ます。

そしてデベロッパーツール上で欲しい結果が得られるようになったら、そのコードをスクリプトファイルに貼り付ければそれだけでもうスクレイピングの完成です。このスクリプトを実行すると以下のような結果が得られます。

```bash
$ ./index.mjs 
[
  '曇',       '曇一時雨',
  '曇一時雨', '曇',
  '曇',       '曇時々晴',
  '曇時々晴'
]
```

従来に比べると革命的に楽に書ける事がお分かりいただけたのではないでしょうか。

さて、では約束通り詳細な解説を加えていきましょう。

```js
#!/usr/bin/env node
```

今回、コマンドラインから直接スクリプトを実行しようかなと思ったので追加しています。`node` コマンドにファイルを渡して実行するのであれば不要です。

```js
import fetch from 'node-fetch';
import jsdom from 'jsdom';

const { JSDOM } = jsdom;
```

`import` 記法が使えるようになったのは嬉しいのですが、v14 のデフォルトではファイルの拡張子を `.mjs` にしておく必要があるので注意して下さい。また `jsdom` に関しては直接 `import { JSDOM } from 'jsdom'` と書きたくなるところですが、現状では `jsdom` が ES2015 Modules 構文をサポートしていないため、こういったまどろっこしい書き方になります。

```js
(async () => {
    // ...
})();
```

非同期処理があるので `await` を使いたいのですが、`await` 自体も非同期関数の中じゃないと使えないので、非同期の無名関数を作って即時実行しています。

```js
    const res = await fetch('https://www.jma.go.jp/jp/week/319.html');
    const html = await res.text();
```

Web プログラミングで見慣れた書き方ですね。非同期に `fetch` した結果から、HTML を文字列として取得しています。`XHR` を使っていた期間が長かったので私もうっかり間違えがちですが、`XHR` の `responseText` と違って、`fetch` で得られるレスポンスの `text` メソッドは非同期なのでそこにも注意が必要です。

```js
    const dom = new JSDOM(html);
    const document = dom.window.document;
```

さあ本記事の最大の見せ場です。`JSDOM` コンストラクタに HTML を文字列で渡すと、それをパースして DOM ツリーにしてくれます。そこには Web プログラミングでおなじみ、`window` オブジェクトがあり、その中に `document` オブジェクトがあります。

```js
    const nodes = document.querySelectorAll('#infotablefont tr:nth-child(4) td');
    const tokyoWeathers = Array.from(nodes, td => td.textContent.trim());
    console.log(tokyoWeathers);
```

この部分は、デベロッパーツール上で動作確認したものを貼り付けると言っていた部分です。ウェブブラウザ上でだと `:nth-child(4)` に相当する部分を楽に探せるのがいいですね。そこで得られた `NodeList` オブジェクトを `Array.from` で `Array` に変換するというのは、今どきなテクニックかもしれません。

以上でスクリプトの解説は終わりです。

最後に、忘れてはならないのはスクレイピングは最終手段であるという事です。API が提供されているサービスであれば必ずそちらを使うべきですし、やむを得ずスクレイピングする際はサーバに過度な負荷を与えることの無いよう気をつけましょう。