フロントエンド・テストツール比較 Puppeteer #03応用編 SEOツール作ってみた

## はじめに

今回のはNode単体で実行可能なPuppeteerの長所を活かして
Slackとの連係機能を足してみようと思います。
nodeの実行環境はHerokuを使用します。
ちなみに、HerokuではなくGCPに実装しようとすると地雷があり動かすことができなかったので、詳細は[ページ下部](https://qiita.com/creaith/items/1fd1142ce3ec4c694d3c#%E3%81%8A%E3%81%BE%E3%81%91)で見てください。

google analyticsのオーガニック検索結果が取得出来なくて皆困っているので
URLを入力すると、どのキーワードで何番目に表示されてますよ〜的な事ができるようにしてみたいと思います。

### この記事のゴール
お手軽感ハンパないPuppeteerを、価値ある仕組みとしてユーザーへお手軽にお届けできるようにしたいわけです。
その為に、今回はユーザーがSlackを使い、Puppeteerを利用して何かできる仕組みを考えてみたいと思います。

## Slackの設定
slack側のインターフェースとしてまず思い浮かぶのがBotですが、それ以外にもお手軽な仕組として
slash-commandっつーのがあるので、今回はそれを使います。

slash-commandとは「/hoge fuga」と入力すると、指定のURLへfugaをPOSTかGETする仕組みで
3秒以内にレスポンスを返せば、Slackへ解答する事も出来ます。
※3秒以上必要な場合はresponse_urlに別途POSTする必要があります。
Botで使うEventApiよりも入力が直球になるので私はこっちの方が好きです。

### **Slack.slash-commandをセットアップ**
[公式Docs](https://api.slack.com/slash-commands)

1. [Custom slash commands](https://slack.com/apps/A0F82E8CA-slash-commands)から"Add configuration"をクリック
2. "Choose a Command"で自分の登録したいコマンドを入力する->Add Slash Command Integrationをクリックする
例： /gsr (gsrとは今回作成したアプリ用に作った便宜上の名前なので任意です)
3. Integration Settingsの"URL"にAPIのエンドポイントを入力します。
4. 後はほぼおまけでAPIに合わせてMethodやらTokenやらを調整して行きます。
5. Save Integrationを押して設定は完了です。

slackに「/gsr」と入力して「405_client_error」が表示されれば反応してるので設定は完了です。
url_verificationとか一切なくお手軽感ハンパないですね。


## Herokuの設定
HerokuリポジトリのmasterにPushするだけでアプリをDeployできる優秀なCLIがあり
それを利用して作業する環境を設定していきます。
また、HerokuはDynoと言う単位での課金ですが、無料枠があり支払い口座情報の登録すらせずに利用できます。
その為、簡単なテストや実験向きのプラットホームとなっております。
>550.00 free dyno hours remaining this month

毎月550時間/dynoの無料枠とは太っ腹ですね、大好きです。
ただし最近アカウントを作成した人がどうかは不明です。


### **Herokuの環境をセットアップ**
[公式ドキュメント](https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up)
- [Heroku](https://jp.heroku.com/home)にアカウントを作成します
- Localに[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli)をBrewでインストール。インストーラーも置いてます。

```bash
$ brew install heroku/brew/heroku
```
- Localのターミナルでheroku cliにログインします

```bash
$ heroku login
# herokuアカウントのemail, passwordを入力します。
```

- 開発用ディレクトリを整備

```bash
# ディレクトリを作成
$ mkdir google-search-result
$ cd google-search-result

# nodeのバージョンを設定(nodenvなど他のやつ使っている人はそれ使ってください)
$ brew install nodebrew
$ nodebrew install stable
$ nodebrew use v8.11.1

# Gitを設定
$ git init

# npmを設定
$ npm init -y
$ npm i puppeteer --save
$ npm i koa koa-router koa-bodyparser -- save

# gitignoreを作成（おまけです） gibo -lで設定可能な値を確認出来ます
$ brew install gibo
$ gibo macOS >> .gitignore
$ gibo JetBrains >> .gitignore
$ gibo Node >> .gitignore

# Herokuを設定
$ heroku create google-search-result
$ heroku git:remote -a google-search-result

# puppeteerのDeployに必要なビルドパックを設定
$ heroku buildpacks:set heroku/nodejs
$ heroku buildpacks:add https://github.com/CoffeeAndCode/puppeteer-heroku-buildpack

# HerokuにDeploy時の実行設定ファイルを作成
$ echo "web: node index.js" > Procfile

# Herokuにpush & deploy
$ git add .
$ git commit -am "make it better"
$ git push heroku master
```
これで「Heroku Git」にpushするだけでガンガンデプロイできる開発環境が整いました。


## テスト用のソースコード

```javascript:index.js
const puppeteer = require('puppeteer');
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');

const app = new Koa();
const router = new Router();
const LAUNCH_OPTION = process.env.DYNO ? { args: ['--no-sandbox', '--disable-setuid-sandbox'] } : { headless: false };

router.post('/', async (ctx, next) => {
  const body = ctx.request.body;
const text = body.text;
const keys = text.split(' ',3)
console.log('*text***')
console.log(text)
console.log('*keys***')
console.log(keys)
console.log('*ctx.request.body***')
console.log(ctx.request.body)
ctx.body = await crawler(keys);
});

app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(process.env.PORT || 3000);

const crawler = async (keys) => {

  // 初期設定
  const google = "https://www.google.co.jp/";
  const maxKeyNum = 3;    // 最大いくつまでキーワード検索を行うか(変更可能)
  const maxCrawlPage = 3; // 最大何ページまで検索を行うか

  // キーワードを「1」[1 2],[1 2 3]と検索するかのフラグ。 falseの場合は一つずつキーワード検索を行う
  const keyJoinFlag = true;

  // 検索に使うキーワード
  const searchUrl = keys[0];
  const searchKeys = keys.slice(1);

  // ブラウザを起動
  const browser = await puppeteer.launch(LAUNCH_OPTION);
  const page = await browser.newPage();

  // 引数でキーワードがなければ指定したURLのキーワードを取得
  if (searchKeys.length === 0) {
    console.log('キーワードを' + searchUrl + 'から取得します');
    await page.goto(searchUrl);
    const getSearchKey = await page.evaluate(() => {
      const keywords = document.querySelector('head meta[name="keywords"]').getAttribute('content');
    const splitKeywords = keywords.split(',');
    return splitKeywords;
  });
    searchKeys = getSearchKey;
  }

  // 検索開始
  let iputKey = '';
  let addKey = '';
  let attachments = [];
  keySearch: for (var i = 0; i < searchKeys.length; i++) {
    if (i === maxKeyNum) break;

    inputKey = `${addKey} ${searchKeys[i]}`;
    console.log(`-----検索を「${inputKey}」で行います-----`);

    // google検索
    await page.goto(google);
    await page.type('input#lst-ib', inputKey);
    await page.click('input[name="btnK"]');
    await page.waitFor(1000);

    // // 検索結果一覧
    // var search_result = await page.$$('div.srg div.g');

    // 検索結果一覧から対象URLを検索
    let rank = 1;
    let hitFlag = false;

    crawl: for(var searchPage = 0; searchPage < maxCrawlPage; searchPage++){
      // 検索結果一覧
      var search_result = await page.$$('div.srg div.g');
      rankCheck:for(var order = 0; order < search_result.length; order++){
        if (await search_result[order].$(`a[href="${searchUrl}"]`)){
          hitFlag = true;
          console.log(`-----${rank}件目でHITしました-----`);
          break;
        }
        rank++;
      }
      if (hitFlag) break;
      // 次のページおす処理
      await page.click('#pnnext');
      await page.waitFor(1000);
    }
    if (hitFlag === false) console.log('----圏外----');
    attachments.push({text:`keyword=${inputKey}, rank=${hitFlag === true ? rank : '圏外'}`});
    if(keyJoinFlag) addKey = inputKey;
  }

  browser.close();
  const slackBody = {
    response_type: 'in_channel',
    text: `${searchUrl}の検索結果`,
    "attachments": attachments
  };
  return slackBody;
}
```


```javascript:package.json
{
  "name": "{アプリ名}",
  "version": "1.0.0",
  "description": "none",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "koa": "^2.5.0",
    "koa-bodyparser": "^4.2.0",
    "koa-router": "^7.4.0",
    "puppeteer": "^1.3.0",
    "save": "^2.3.2"
  },
  "devDependencies": {},
  "repository": {
    "type": "git",
    "url": "https://git.heroku.com/{アプリ名}.git"
  }
}
```

## ローカルでテストを実行する
Herokuで実行せずローカルで実行したい場合はindex.jsが格納してあるディレクトリで以下を叩いてください。

```shell

$ node index.js
# ブラウザでlocalhost:3000
```


## 結果
slackで発言してみましょう。

```
/gsr http://creaith.jp creaith
```
![スクリーンショット 2018-04-25 20.53.55.png](https://qiita-image-store.s3.amazonaws.com/0/240629/d214d7f4-5d71-d4b9-cdb7-96653aeecae7.png)

nodeだけで作られている強みをいろんなところで活かせそうです。




## おまけ

### 俺がハマった落とし穴編です。

当初は無料枠が残ってたので「GoogleCloudPlatform」の「cloud function」でと思い、途中までやったんですがっ！！！　
結論から言うと[puppeteerのissues](https://github.com/Googlechrome/puppeteer/issues/290)でも議論されてる通り、
「cloud function」ではChromeの実行に必要な依存関係「[libX11-xcb](https://packages.debian.org/sid/libx11-xcb1)」が入っておらず
chromeが立ち上がらない為、'puppeteerは使えません。

```
Error: Failed to launch chrome! /user_code/node_modules/puppeteer/.local-chromium/linux-549031/chrome-linux/chrome: error while loading shared libraries: libX11-xcb.so.1: cannot open shared object file: No such file or directory TROUBLESHOOTING: 
```

puppetterの[troubleshooting](https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch)にも記載されていました。
うーん、課金までしたのに残念。

余談ですが、原因を探っている時に
[try-puppeteer](https://try-puppeteer.appspot.com/)と言う、ブラウザで試せるpuppeteerに出会いおもしろいなと思ったので紹介しておきます。


### puppeteerは使えませんが、ついでなのでGoogle Cloud Platform をセットアップ方法も紹介しておきます
[公式Tutorial](https://cloud.google.com/functions/docs/tutorials/slack)

1. [プロジェクトを作成します](https://console.cloud.google.com/cloud-resource-manager)
2. [請求先アカウントを登録します](https://console.cloud.google.com/billing)
3. [CloudFunctionsで関数を作成します](https://console.cloud.google.com/functions/list)
4. 関数名を決めます。
5. トリガーを「Httpトリガー」を選択する
6. ソースコード「インラインエディタ」を選択する
7. 作成ボタンを押して完了です

関数の詳細画面のトリガータブにエンドポイントがあるのでこれをSlack側のURLに貼り付ければ完了
あとはソースを変更していくだけです。
googleさんもHerokuのようにデプロイまでできる開発環境がありますがここでは触れません。

### 対するSelenium班
[フロントエンド・テストツール比較 Selenium #03初心者でもわかる入門編] (https://qiita.com/creaith/items/b1e9e04a70e0059ee601)
* [志村](https://qiita.com/naoto1222)、[モトキ](https://qiita.com/pickles) 

## Creaithメンバー
この記事の著者：[上田](https://qiita.com/uedashuhei)、[曽宮](https://qiita.com/ryosomiya)
その他メンバー：[志村](https://qiita.com/naoto1222)、[モトキ](https://qiita.com/pickles)