【NightmareJS】自動でFF14のロドスト日記を検索してスクリーンショットを保存

# 前書き
[NightmareJS](http://www.nightmarejs.org/)を触ってみたら面白かったので少し趣味と絡めてみました。
ver.2からElectronベースのブラウザを表示できるようになったり、ES6で書けるようになったりと扱いやすくなってます。
今回作ったものは実用性があるかと言われれば正直微妙なので、あくまでNightmareJSの紹介・入門記事と思ってください。

# 要件
・[ロドストの日記ページ](https://jp.finalfantasyxiv.com/lodestone/blog/)から日記を検索。
・検索の際に指定する情報は以下の通り。

|      項目      |      入力値      |
|:-------------:|:---------------:|
|    キーワード    |       4.4       |
|      タグ      |攻略, 固定, 零式・絶|
|  データセンター   |      Gaia      |

・検索結果の内、最新５件の日記内容のスクリーンショットを撮り保存する。
・実行した月・日・時間を元にフォルダを作成し、そこにスクリーンショットを保存する。

# 準備
前提としてNode.jsが必要なので、まだPCに入っていないという方はインストールしておきましょう。
あとはnpmでNightmareJSをインストールすれば、ひとまず必要なものは揃います。

```sh
npm install nightmare
```

# 処理内容
今回は`ff_diary.js`という名前でJSファイルを用意しました。
中身はこんな感じになっています（詳細はこの後解説します）。
手っ取り早く動きを確認したい人は丸っとコピーして`$ node ff_diary.js`って叩けば動くはず。

```js:ff_diary.js
const fs = require('fs');
const NIGHTMARE = require('nightmare');
const SCREEN_WIDTH = 960;
const SCREEN_HEIGHT = 540;
const APP = NIGHTMARE({
  width: SCREEN_WIDTH,
  height: SCREEN_HEIGHT,
  show: true, // ブラウザを表示するか
  enableLargerThanScreen: true // ブラウザを画面より大きく出来るか
});
let scrapingCount = 1;
const SCRAPING_LIMIT = 5;

// ロドストにアクセスして日記を検索
const SEARCH = () => {
  APP.goto('https://jp.finalfantasyxiv.com/lodestone/blog/')
    .wait('.form__horizontal')
    .click('.icon-btn__search_28')
    .insert("input[placeholder='キーワードを入力']", false)
    .insert("input[placeholder='キーワードを入力']", '4.4')
    .select('.sys_tag_presets', '攻略')
    .select('.sys_tag_presets', '固定')
    .select('.sys_tag_presets', '零式・絶')
    .select('select[name="worldname"', '_dc_Gaia')
    .click('input[value="検索"]');

  CHECK();
};

// 日記詳細をチェック
const CHECK = () => {
  SHOW_DETAIL().then(r => {
    SCREENSHOT(r);
  });
};

// 詳細を表示
const SHOW_DETAIL = () => {
  return new Promise((resolve, reject) => {
    APP.wait('.ldst__contents')
      .click(
        `.entry__block__wrapper .entry__blog_block:nth-child(${scrapingCount}) .entry__blog_block__box a`
      )
      .wait('.ldst__window')
      .evaluate(() => {
        const $BODY = document.querySelector('body');

        // ページ全体の幅と高さを返す
        return {
          width: $BODY.scrollWidth,
          height: $BODY.scrollHeight
        };
      })
      .then(result => {
        resolve(result);
      });
  });
};

// スクリーンショット保存
const SCREENSHOT = r => {
  const TODAY = new Date();
  const MONTH = TODAY.getMonth() + 1;
  const DATE = TODAY.getDate();
  const HOUR = TODAY.getHours();
  const FOLDER_NAME = `${MONTH}月${DATE}日${HOUR}時`;

  // フォルダが無ければ作成
  if (!fs.existsSync(`./${FOLDER_NAME}`)) {
    fs.mkdirSync(`./${FOLDER_NAME}`);
  }

  APP.viewport(r.width, r.height)
    .wait(1000)
    .screenshot(`./${FOLDER_NAME}/screenshot${scrapingCount}.jpg`)
    .then(() => {
      if (scrapingCount === SCRAPING_LIMIT) {
        APP.end();
      } else {
        scrapingCount += 1;
        APP.viewport(SCREEN_WIDTH, SCREEN_HEIGHT).back();
        CHECK();
      }
    });
};

// 実行開始
SEARCH();
```

## 初期設定
まずは初期設定部分から。

```js:ff_diary.js
const fs = require('fs');
const NIGHTMARE = require('nightmare');
const SCREEN_WIDTH = 960;
const SCREEN_HEIGHT = 540;
const APP = NIGHTMARE({
  width: SCREEN_WIDTH,
  height: SCREEN_HEIGHT,
  show: true, // ブラウザを表示するか
  enableLargerThanScreen: true // ブラウザを画面より大きく出来るか
});
let scrapingCount = 1;
const SCRAPING_LIMIT = 5;
```

最初に`fs`と`nightmare`を`require`しています。
`fs`はNode.jsで使用できるファイルシステム関数です（詳細は省きます）。
今回は後述するフォルダの存在確認と作成に使用しています。

ブラウザのデフォルトサイズを変数に入れた後、NightmareJSのオプションを指定しています。
`width`、`height`でブラウザのサイズを指定。
`show`を`true`にすると、実行時にElectronベースのブラウザが立ち上がるので挙動を見ることができます。別に見えなくていいよって人は`false`に。
`enableLargerThanScreen`は、`true`にしておけばブラウザのサイズを画面サイズより大きくできます。
今回はページ全体のスクリーンショットを撮りたいので`true`にします。

`scrapingCount`と`SCRAPING_LIMIT`は、何件の日記を表示したか判定するために用意しています。

## 日記の検索
お次は日記の検索処理です。

```js:ff_diary.js
// ロドストにアクセスして日記を検索
const SEARCH = () => {
  APP.goto('https://jp.finalfantasyxiv.com/lodestone/blog/')
    .wait('.form__horizontal')
    .click('.icon-btn__search_28')
    .insert("input[placeholder='キーワードを入力']", false)
    .insert("input[placeholder='キーワードを入力']", '4.4')
    .select('.sys_tag_presets', '攻略')
    .select('.sys_tag_presets', '固定')
    .select('.sys_tag_presets', '零式・絶')
    .select('select[name="worldname"', '_dc_Gaia')
    .click('input[value="検索"]');

  CHECK();
};

// 日記詳細をチェック
const CHECK = () => {
  SHOW_DETAIL().then(r => {
    SCREENSHOT(r);
  });
};
```

`goto()`で指定のURLに遷移し、`wait()`で指定の要素が読み込まれるまで待機します。
要素の指定はjQueryライクな書き方ができます。
今回指定している`.form__horizontal`はこの部分です。
<img width="476" alt="form__horizontal.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/266579/4e30a54e-2f1a-8f7a-18c2-55a65405b9d4.jpeg">

虫眼鏡アイコンをクリックする度にフォーム部分の表示・非表示が切り替わる（デフォルト非表示）ようなので、`.form__horizontal`が読み込まれたら虫眼鏡アイコンを`click()`。
これでフォーム部分が表示されました。

あとは`insert()`でキーワードの部分に文字を入れ、`select()`でタグとデータセンターを選択して検索ボタンを`click()`です。

`insert()`の処理が`.insert("input[placeholder='キーワードを入力']", '4.4')`の一文だけだと、テキストボックスにフォーカスされるだけで値が入力されないことがあったため、第二引数に`false`を指定した処理を先に行うことで回避しています。

`select()`の引数は、`(select要素のセレクタ, option要素のvalue値)`を渡してあげます。

※`type()`でもテキストボックスに値を入力することができます。`type()`では指定の文字が一文字ずつ入力され、`insert()`ではまとめて入力されます。状況に応じて使い分けましょう。

## 日記の詳細を表示
日記を検索できたら次は詳細ページにアクセスします。

```js:ff_diary.js
// 詳細を表示
const SHOW_DETAIL = () => {
  return new Promise((resolve, reject) => {
    APP.wait('.ldst__contents')
      .click(
        `.entry__block__wrapper .entry__blog_block:nth-child(${scrapingCount}) .entry__blog_block__box a`
      )
      .wait('.ldst__window')
      .evaluate(() => {
        const $BODY = document.querySelector('body');

        // ページ全体の幅と高さを返す
        return {
          width: $BODY.scrollWidth,
          height: $BODY.scrollHeight
        };
      })
      .then(result => {
        resolve(result);
      });
  });
};
```

検索結果画面に遷移中なので、ひとまず`.ldst__contents`が読み込まれるまで待ちます。
`.ldst__contents`より下のバナー、フッターエリアは必要ないのでそこまで読み込みを待つ必要はありません。
<img width="476" alt="ldst_contents.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/266579/69eb8657-b726-b24a-702f-c00db987346e.jpeg">

読み込めたらn番目の記事のリンクを`click()`し、また指定の要素が読み込まれるまで待ちます。
`.ldst__window`が読み込まれていれば、その中に記事とコメントが含まれているので大丈夫そうです。
<img width="477" alt="ldst__window.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/266579/098bbeef-4065-854c-0b9f-852718e7cfbb.jpeg">

この読み込みが完了したら`evaluate()`内でページ全体のサイズを取得し、後述するスクリーンショットを撮る関数に渡します。
`evaluate()`では、**NightmareJSで動いているブラウザの中で実行されるJS**を書くことができます。
よって、`scrollWidth`、`scrollHeight`で今表示している日記詳細ページのサイズが取得できます。

## スクリーンショットの撮影・保存
日記詳細ページが表示されたらスクリーンショットを撮影し、保存します。

```js:ff_diary.js
// スクリーンショット保存
const SCREENSHOT = r => {
  const TODAY = new Date();
  const MONTH = TODAY.getMonth() + 1;
  const DATE = TODAY.getDate();
  const HOUR = TODAY.getHours();
  const FOLDER_NAME = `${MONTH}月${DATE}日${HOUR}時`;

  // フォルダが無ければ作成
  if (!fs.existsSync(`./${FOLDER_NAME}`)) {
    fs.mkdirSync(`./${FOLDER_NAME}`);
  }

  APP.viewport(r.width, r.height)
    .wait(1000)
    .screenshot(`./${FOLDER_NAME}/screenshot${scrapingCount}.jpg`)
    .then(() => {
      if (scrapingCount === SCRAPING_LIMIT) {
        APP.end();
      } else {
        scrapingCount += 1;
        APP.viewport(SCREEN_WIDTH, SCREEN_HEIGHT).back();
        CHECK();
      }
    });
};
```

最初に今日の月、日、時間を取得してフォルダ名を設定し、それを元に`fs.existsSync()`でフォルダの存在を確認。
もしフォルダが無ければ`fs.mkdirSync()`で作成します。

フォルダが出来たら`viewport()`でブラウザのサイズをページ全体のサイズへ変更し、念のために1秒待ってからスクリーンショットを撮影・保存します。
カウントが上限に達していれば`end()`で処理を終了。

まだ上限になっていなければカウントを1プラスし、`viewport()`でデフォルトのブラウザサイズに戻します。
ここでブラウザサイズを戻しておかないと、次の記事のスクリーンショットを撮る時に余分な空白が生まれてしまう可能性があります。
（例：記事1の中身の高さが5000px、記事2の中身の高さが3000pxだった時、ブラウザサイズを元に戻しておかないと高さ5000pxの状態のブラウザで記事2にアクセスしてしまうので、`scrollHeight`の値が5000pxになってしまい2000pxの余白ができる。）

その後`back()`で一つ前の画面（検索結果画面）に戻り、日記の詳細表示→スクリーンショットの撮影・保存を再度実行します。

# おまけ
以上で日記の検索→詳細表示→スクリーンショットの撮影・保存という一通りの流れが完成しましたが、**別にこれやらなくても直接日記見に行けばいいじゃん**って感じなので定期的にnodeのコマンドを実行できるようにしてみましょう。

## watchコマンドのインストール
定期的にコマンドを実行する方法はいくつかありますが、今回は手っ取り早く`watch`コマンドにしたいと思います。
まずは`watch`コマンドをインストールしましょう。

```sh
# Homebrewが無い場合は先にHomebrewをインストール
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

# watchのインストール
$ brew install watch
```

## watchコマンドの実行
とりあえず今回は1時間毎に実行するようにしたいと思います。
実行間隔の設定は`-n`オプションで設定できます。

```sh
# 1時間毎に実行（間隔の単位は秒）
$ watch -n 3600 node ff_diary.js
```

他にもエラー時にビープ音を鳴らす`-b`、出力結果に色を付ける`-c`など色々オプションがありますが今回は特に必要ないので省略。

これで1時間毎に自動で日記を検索し、最新の5件の内容をスクリーンショットで保存しておいてくれるようになりました。
~~**・・・やっぱり直接日記見ればよくない？**~~