JavaScriptでShowRoom星集め自動化スクリプト

## 概要
[ShowRoom](https://showroom-live.com)という生配信アプリの星集めを自動化するスクリプトを書いたので、紹介したいと思います。

## 環境
PC: `Mac` </br>
使用言語: `Node.js(v10.4.1)` </br>
パッケージ管理: `npm(6.9.0)` </br>
スクレイピングツール: [`Selenium-Webdriver`](https://www.seleniumhq.org/docs/03_webdriver.jsp)


## 前提条件
Node, chromedriverはインストール済みとします

## パッケージインストール
まずは必要なNodeのパッケージをインストールします。

`$ npm install selenium-webdriver` <= nodeのselenium-webdriverパッケージ
`$ npm install axios` <= httpリクエストモジュール
たったのこれだけです！簡単！

## 実装
それでは実装に移っていこうと思います。

#### 実装内容
星集めの流れとしては
1. ログイン
2. 配信中のライブを確認
3. 各配信ページでツイートボーナス取得

なお、今回はツイートボーナスのみに限定しているので視聴ボーナスは対象としていません。

#### 処理の流れ
プログラムの大枠としては以下のようになっています。

```javascript
// メイン関数
async function main() {
  // ======== Environment ==============
  // Credential
  LOGIN_USER_NAME = '*****'
  LOGIN_PASSWORD  = '*****'
  // SHOWROOM URL
  BASE_URL = 'https://showroom-live.com'
  // client
  BROWSER = 'chrome'
  // ===================================

  const credentials = {
    user_name: LOGIN_USER_NAME,
    password: LOGIN_PASSWORD
  }
  // options
  const options = {
    browser: BROWSER
  }

  // ドライバ起動
  let driver = await exec_driver(options)  
  // ホームページ起動
  await driver.get(BASE_URL);
  // ログイン
  driver = await login(driver, credentials)
  // 配信中のユーザURL取得
  const lives_raw = await getOnliveIDs(BASE_URL) 
  const shaped_lives = shapeLivesRaw(lives_raw)
  // ツイートボーナス取得
  await getTweetBonus(driver, shaped_lives, BASE_URL)
}
```


#### WebDriver起動
まずはWebDriverを起動してみたいと思います。

```javascript
const webdriver = require('selenium-webdriver');
const { Builder, By, until } = webdriver;

// ドライバ起動
async function exec_driver(options) {
  let capabilities;
  if (options.browser === 'chrome') {
    capabilities = webdriver.Capabilities.chrome();
    capabilities.set('chromeOptions', {
      args: [
        '--no-sandbox',
        '--disable-gpu',
        '--window-size=1980,1200'
      ]
    })
  };
  const driver = await new Builder().withCapabilities(capabilities).build();  
  return driver
}


async function main() {
  // client
  BROWSER = 'chrome' // GoobleChromeを使用

  // options
  const options = {
    browser: BROWSER
  }

  // ドライバ起動
  let driver = await exec_driver(options)  
}

main()
```

GoogleChromeが起動したと思います。細かいオプションがありますがとりあえずこれで良いです。

#### 指定したURLを開く
`driver.get(url)`で指定したURLのページを開くことができます。

```javascript
async function main() {
  ...
  // ドライバ起動
  let driver = await exec_driver(options)  
  // 開きたいページのURL
  BASE_URL = 'https://showroom-live.com'
  // ホームページ起動
  await driver.get(BASE_URL);
}
```

#### ログイン処理
次にログイン処理です。WebDriverで起動したChromeはセッションを保持していないので毎回ログインする必要があります。それでは`login()`関数を実装していきます。

```javascript
// ログイン処理
function login(driver, credentials) {

  return new Promise(async function(resolve) {
    // ログインボタンクリック
    await driver.findElement({ css: 'ul.side-nonuser-menu li:nth-child(2) a'}).click()
    // ツイッターでログインボタンクリック
    await driver.findElement({ css: '#js-login div:nth-child(3) a'}).click()
  
    // ウィンドウが開くまで待つ  
    setTimeout(async () => {
      let url = await driver.getCurrentUrl()
      // 全ウィンドウ取得
      const handle_array = await driver.getAllWindowHandles();
      // ウィンドウ移動
      await driver.switchTo().window(handle_array[1])
      // ログインフォーム入力
      await driver.findElement(By.id("username_or_email")).sendKeys(credentials.user_name)
      await driver.findElement(By.id("password")).sendKeys(credentials.password)
      // ツイッター連携許可ボタンを押す
      await driver.findElement(By.id("allow")).click()
  
      // ログイン後の画面に遷移
      setTimeout(async () => {
        // 全ウィンドウ取得
        const handle_array = await driver.getAllWindowHandles();
        // ウィンドウ移動
        await driver.switchTo().window(handle_array[0])
        resolve(driver);
      }, 3000);
    }, 3000)  
  })
}

async function main() {
  ...
  // Credential
  LOGIN_USER_NAME = '********'
  LOGIN_PASSWORD  = '********'

  const credentials = {
    user_name: LOGIN_USER_NAME,
    password: LOGIN_PASSWORD
  }

  await driver.get(BASE_URL)
  login(driver, credentials) // <= 追記
}
```

ポイントは
1. `driver.findElement()`のcssセレクタでhtml要素を見つけてクリック
2. ツイッターでログインのウィンドウが開くのを`setTimeout`で待機している
3. `driver.getAllWindowHandles()`で開いているウィンドウのリストを取得して`driver.switchTo().window(handle_array[1])`でツイッターでログインのウィンドウに遷移している
4. `element.sendKeys()`でフォーム入力している
5. ログイン後再び`setTimeout`で待機してウィンドウを移動している

以上になります。

#### 配信中のユーザ一覧取得
次に、ボーナスを取得する配信のURL一覧を取得します。星集めと言っていますが一応非公式ユーザの情報も保存しておきます。

```javascript
// ライブ配信中のID取得
function getOnliveIDs(url) {
  return new Promise(async function(resolve) {
    axios.get(`${url}/api/live/onlives`)
      .then(res => {
        const onLives = res.data.onlives
        // 公式ユーザ
        const official_lives = onLives.filter(
          lives => Object.keys(GENRE_ID_LIST.official).includes(String(lives.genre_id))
        )
        // 非公式ユーザ
        const non_official_lives = onLives.filter(
          lives => Object.keys(GENRE_ID_LIST.non_official).includes(String(lives.genre_id))
        )
        const lives = {
          official: official_lives,
          non_official: non_official_lives
        }
        resolve(lives)      
      })
      .catch(err => {
        console.log(err);
      })
  })
}

// 取得したライブ情報を整形する
function shapeLivesRaw(lives) {
  let official_room_url_keys = []
  let nonofficial_room_url_keys = []
  // 公式ユーザ
  for (i in lives.official) {
    const room_infos = lives.official[i].lives.filter(
      elem => elem.room_url_key
    )
    for (j in room_infos) {
      official_room_url_keys.push(room_infos[j].room_url_key)
    }
  }
  // 非公式ユーザ
  for (i in lives.non_official) {
    const room_infos = lives.official[i].lives.filter(
      elem => elem.room_url_key
    )
    for (j in room_infos) {
      nonofficial_room_url_keys.push(room_infos[j].room_url_key)
    }
  }
  return {
    official: official_room_url_keys,
    non_official: nonofficial_room_url_keys
  }
}

const axios = require('axios') // axiosモジュール読み込み

// グローバル変数に配信ジャンルIDリストを記述
GENRE_ID_LIST = {
  official: {
    // Official
    '101': "Music",
    '102': "Idol",
    '103': "Talent Model",
    '104': "Voice Actors & Anime",
    '105': "Comedians/Talk Show",
    '107': "Virtual",
  },
  non_official: {
    // Non-Official
    '703': "Karaoke",
    '704': "MEN'S",
    '200': "Non-Professionals"
  }
}

async function main() {
  ...
  // 配信中のユーザURL取得
  // 追記
  const lives_raw = await getOnliveIDs(BASE_URL) 
  const shaped_lives = shapeLivesRaw(lives_raw)
}
```

ポイントは
1. `axios`を使って https://showroom-live.com/api/live/onlives にリクエストをして配信中のライブの情報を取得している
2. ジャンルIDが`101`~`107`の場合は公式ユーザそれ以外は非公式ユーザとなっている点です。(`MEN'S`ジャンルについてはメンズの公式かもですが非公式があったらいやなので非公式に分類しています。)
3. json形式のレスポンスを`shapeLivesRaw()`で整形して配信者URLを取得している

※実際にjsonを見ればわかりますが一部ちょっとクセのあるjson形式でした

#### ツイートボーナス取得
さて、ここからやっとツイートボーナスを取得していきます。

```javascript
// ツイートボーナス取得
async function getTweetBonus(driver, lives, url) {
  const official = lives.official
  // sleep関数実装
  const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
  // 
  for await (const id of official) {
    await driver.get(`${url}/${id}`)
    try {
      // ツイートフォームを開く
      await driver.findElement(By.id("icon-room-twitter-post")).click()
      // ツイートボタンクリック
      await driver.findElement(By.id("twitter-post-button")).click()            
      await sleep(1500)
      const message = await driver.findElement({css: ".toast-message"})
      const dialog = await message.getText()
      const limit = /無料ギフトの獲得は\d{2}:\d{2}まで制限されています/g;
      const duplicate = "同じ文面は連続で投稿できません。"
      // ボーナス取得制限がきたら取得をやめる`
      if (dialog.match(limit)) {
        console.log("done");
        await driver.quit()
        return 
      }
    }
    catch {
      console.log("エラー");
    } 
  }
  driver.quit()
}

async function main() {
  ...
  // ツイートボーナス取得
  await getTweetBonus(driver, shaped_lives, BASE_URL)
}
```

ポイントは
1. https://showroom-live.com/:配信者ID にアクセスしてライブページを開く
2. ツイートボタンクリック後にメッセージの内容で星集めが完了したか判定している。


## まとめ
いかがだったでしょうか？selenium-webdriverは非同期処理で要素が見つからなかったりすることがあるので難しいイメージだったんですがjavascriptの`async&await`を使って意外と簡単に実装できました。`setTimeout()`に余裕を持たせてリクエストの間隔をあけるなどしてサーバに優しい実装にしましょう。お疲れ様でした。