puppeteerでポイ活を自動化しよう

Webサービスでポイントを貯める（ポイ活）ため毎日のようにブラウザを手動で操作していませんか？
そんなときはブラウザ操作を完全自動化して楽をしましょう！

ツールってほどではないですが参考にどうぞ。
もちろんポイ活以外の操作も同様に自動化できます。

ソース（動けばいいってコードなので汚さはご容赦を…）:

https://github.com/shiraily/job-scheduler/tree/v1.0.1

## サマリ

* 「SBI証券のWebサイトを毎日チェックし、新規IPOがあれば申し込む」というのを自動化した。申し込んでも大抵落選するのでIPOチャレンジポイントが貯まる。
* 「Puppeteerでブラウザ操作」するコードをApp Engineへデプロイし、cronで自動実行する。
* 毎月のGCP代は無料枠のおかげで10円だが、頻繁に稼働する場合は料金に注意

## 自動化していること

- (日次) SBI証券のIPOチェックと申し込み自動化
  - 本記事で説明します。
  - 例えば2020年はコロナ禍でも85社、つまり85回申し込めたので、手動ではさすがに面倒です。
- (月次) 新生銀行のポイントプログラム自動エントリー
  - 毎月[フォーム](https://webform.shinseibank.com/webapp/form/19913_xldb_4/index.do?lid=p)からエントリーすることで月の実績に応じたTポイントなどが得られます。毎月入力させてタッチポイントを増やそうとしているのでしょうか…?とても面倒です。
  - 私は[エントリーを自動化](https://github.com/shiraily/job-scheduler/blob/v1.0.1/src/entry_shinsei_bank.js)して毎月Tポイントを約30ポイント獲得しています:innocent:

## 開発

以下、コードは一部をピックアップしたものです。

### 主な使用技術

- App Engine (スタンダード環境)
- Node.js / JavaScript
- Puppeteer (ブラウザ操作用Nodeライブラリ)

### Puppeteerによるブラウザ操作

ブラウザの起動。`puppeteer.launch`でブラウザを起動しつつBrowserオブジェクトを取得します。
App Engineのより小さいインスタンスで動くよう、起動オプション(Chromiumの一覧は[こちら](https://peter.sh/experiments/chromium-command-line-switches/))をいくつかつけてメモリの使用量を減らします。

```apply_ipo.js
import puppeteer from "puppeteer";

export const applyIPO = async () => {
  const browser = await puppeteer.launch(
    {
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '-–disable-dev-shm-usage',
        '--disable-gpu',
        '--no-first-run',
        '--no-zygote',
        '--single-process',
      ],
    }
  );
  try {
    // ブラウザの操作内容を書く
    await _apply(browser);
  } catch(e) {
    console.log("error", e);
  } finally {
    browser.close();
  }
};
```

次に、ブラウザの操作内容です。

```apply_ipo.js
const _apply = async (browser) => {
  console.log("start applying IPO");

  // タブの初期化
  const page = await browser.newPage();
  page.setViewport({width: 1280, height: 720});
  console.log("succeeded to launch browser");

  // ログイン。環境変数からの値取得方法は後述
  await page.goto("https://www.sbisec.co.jp/");
  await page.type("input[name=user_id]", process.env.SBI_USERNAME);
  await page.type("input[name=user_password]", process.env.SBI_PASSWORD);
  await page.click("input[name=ACT_login]");

  // 1度に5件まで連続して申し込む
  let n = 0;
  while (n < 5) {
    await page.waitForSelector("img", {timeout: 5000});
    await page.goto("https://m.sbisec.co.jp/oeliw011?type=21", { waitUntil: "domcontentloaded" });

    let canApply = true;
    await page.waitForSelector("img[alt=申込]", {timeout: 5000}).catch((e) => {
      if (!(e instanceof puppeteer.errors.TimeoutError)) {
        console.error(e);
      }
      canApply = false;
    });

    // これ以上申し込み不可なら終了
    if (!canApply) {
      console.log("no more BB");
      break;
    };
    await page.click("img[alt=申込]");

    // 100株で申し込み
    await page.waitForSelector("input[name=suryo]", {timeout: 5000});
    await page.type("input[name=suryo]", "100");
    await page.click("#strPriceRadio");
    await page.type("input[name=tr_pass]", process.env.SBI_TRADE_PASSWORD);
    await page.click("input[name=order_kakunin]");
    await page.waitForTimeout(1000);

    await page.click("input[name=order_btn]");
    console.log(`apply ${n}`);
    n += 1;
  }
}

```

補足

- 単に `waitForTimeout` よりは `waitForSelector`のほうが、操作したいDOM Objectを待つなら望ましいです
- 結果をSlackに投稿したいなら[こんな感じ](https://github.com/shiraily/job-scheduler/blob/v1.0.1/src/entry_shinsei_bank.js#L4)です。

### API用意

App Engineのcronから呼び出せるよう、API化します。

```app.js
import express from "express";
import { applyIPO } from "./apply_ipo";

// .envから
環境変数を読み込み
import dotenv from "dotenv";
dotenv.config();

const app = express();

app.get('/apply-ipo', (_, res, next) => {
  console.log("requested");
  applyIPO().then(
    () => {
      res.status(200).send('OK').end();
      SERVER.close();
    }
  ).catch(next);
});

const PORT = process.env.PORT || 8000;
const SERVER = app.listen(PORT, () => {
  console.log(`App listening on port ${PORT}`);
  console.log('Press Ctrl+C to quit.');
});

module.exports = app;
```

.envは環境変数名とその値を書くので

```
SBI_USERNAME=hoge
...
```

などとなります。

### App Engine

package.jsonにbuild / start を登録します。babelは割愛

```package.json
{
  "scripts": {
    "build": "babel src -d ./dist/",
    "start": "node dist/app.js"
  },
  ...
}
```

appの構成ファイルは↓だけでOKです。F2以下だとメモリ不足でエラーになる場合がありました。

```app.yaml
runtime: nodejs12
instance_class: F4
```

```
gcloud app deploy --version=1 --quiet
```

定期実行するには

```cron.yaml
cron:
- description: Apply IPO on SBI Securities
  url: /apply-ipo
  schedule: every day 1:00
  timezone: Asia/Tokyo
```

などと書いて

```
gcloud app deploy cron.yaml --quiet
```

で登録します。

### セキュリティの強化

申し訳程度ですが、cronスケジューラからのAPIコールのみ許可するfirewallを設定します。

```
gcloud app firewall-rules update default --action=deny
gcloud app firewall-rules create 1 \
--action=allow \
--source-range='10.0.0.1'
gcloud app firewall-rules create 2 \
--action=allow \
--source-range='0.1.0.1'
```

## 費用

ポイントを貯めるのにサーバー費用がかさんでしまっては本末転倒です。
幸いなことに、GCPにはかなりの無料枠があるので毎月10円と安く済んでいます。

- App Engine: 0円
  - [毎日28時間分のFインスタンスが無料](https://cloud.google.com/appengine/quotas#Instances)なのは大きいです。
    - 例: F2インスタンスなら 28/2 = 14時間、F4なら28/4 = 7時間相当が無料
- Cloud Storage: 9円
  - App Engineのcontainer imageに対して課金されます。
    - 未検証ですが[削除しても良い](https://www.serversus.work/topics/s7yrn05l6hstyh9wmli2/)らしいです。
    - [特定のリージョン](https://cloud.google.com/storage/pricing#cloud-storage-always-free)だと無料枠があるようです…
- 消費税: 1円 :innocent:

### 失敗談

5分おきに、とあるチケットをキャンセル待ちチェックしてSlackに通知していたのですが、
解除し忘れて4,515円かかりました。

![gcp_high_cost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88483/78b43b0c-f3f6-1b7c-b452-bfc2d232a16a.png)

App EngineはF2以上のインスタンスを常時稼働するとそれなりにかかるのでご注意ください。

また、急に料金が増えたときに気がつけるよう、[予算アラート](https://cloud.google.com/billing/docs/how-to/budgets?hl=ja&visit_id=637755282131683541-2209652871&rd=1)を設定しましょう！