Playwrightで楽天カードのCSVを自動で取得するクローラーを作った


## はじめに
クレジットカードの利用明細はPDFやCSVでダウンロードすることができますが、これらを手動で取得するのは手間がかかります。
うまく改造すればfreeeへの自動連携なども考えられます。
ビジネスカードのAPI非対応の履歴も取れますので、色々と利用できそうです。
そこで、Playwrightを使って自動化することで、手動での取得作業を自動化することができます。

以下は取得したcsvから月の支払金額をLine通知するサンプルです
https://github.com/natsumeaurlia/rakuten-csv

## 準備
適当な作業ディレクトリを作成。
必要なパッケージのインストール方法
以下のコマンドで、必要なパッケージをインストールします。

```bash
npm init playwright@latest
npm install playwright dotenv
```

### ドライバーのダウンロードと設定方法
Playwrightでは、実行するブラウザに応じたドライバーを必要とします。本クローラーでは、Chromiumを利用するため、以下のコマンドでChromiumのドライバーをダウンロードします。

```bash 
npx playwright install
```

### dotenvを使った環境変数の設定方法
セキュリティ上の理由から、本クローラーではIDとパスワードを環境変数として設定します。.envファイルをプロジェクトのルートディレクトリに作成し、以下のように設定してください。

```.env
ID=<楽天カードのログインID>
PASS=<楽天カードのログインパスワード>
```

## 起動処理
main.ts,tsconfig.jsonを作成してください。

tsconfig.jsonはこのような形です。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2018",                           
    "module": "commonjs",                         
    "strict": true,                               
    "esModuleInterop": true,                      
    "skipLibCheck": true,                         
    "forceConsistentCasingInFileNames": true      
  }
}

```

main.tsはこのような雛形になります。

```typescript:main.ts
import * as dotenv from 'dotenv';

dotenv.config();

(async () => {
    try {

    } catch (err) {
        console.error(err);
    }
})();

```

まずはenvからIDとPASSがあるか確認します
tryブロックの中に追記してください

```typescript
const ID = process.env.ID;
const PASS = process.env.PASS;
if (!ID || !PASS) throw new Error("ID or PASS is not set.");

```

これでID,PASSがなかったら実行されません。

次に以下のように追記してください。

```typescript

const downloadDir = path.join(__dirname, 'storage');
const browser = await chromium.launch({
  headless: true,
  downloadsPath: downloadDir,
  args: [
    '--incognito',
    '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36'
  ]
});
const context = await browser.newContext({
  acceptDownloads: true,
});
const page = await context.newPage();

```

csvの保存ディレクトリを選択。
chromium起動オプションで、シークレットモード、agent偽装、headless、ダウンロード許可状態で起動します。

## ログイン処理

ログイン画面に移動し、3秒待機。
ログイン情報を入力欄に記載しログインボタンを押します。
少しスリープを入れてしっかり画面が開くようにします。

```typescript

await page.goto("https://www.rakuten-card.co.jp/e-navi/index.xhtml");
await page.waitForTimeout(3000);

await page.fill('input[name="u"]', ID);
await page.fill('input[name="p"]', PASS);

await Promise.all([
  page.click('input[id="loginButton"]'),
  page.waitForLoadState('networkidle'),
]);

await page.waitForTimeout(2000);

```

## 明細取得処理

利用明細をcsvにする関数は下記のように表せます。
unixtimeなどをつけてcsv名の重複を防いでいます

```typescript

// 利用明細CSVのダウンロード
const downloadFunc = async () => {
  const downloadPromise = page.waitForEvent('download', {timeout: 10000});
  await page.click('.stmt-c-btn-dl.stmt-csv-btn');
  const download = await downloadPromise;
  const unixtime = Math.floor(new Date().getTime() / 1000);
  await download.saveAs(path.join(downloadDir, unixtime + 'rakuten-card.csv'));
})
```

これを利用してダウンロードは行います。

それでは利用明細に移動します。
移動したら、楽天カードの利用可能なものを取得します。

```typescript:main.ts
// 利用明細画面に遷移する
await Promise.all([
  page.goto('https://www.rakuten-card.co.jp/e- navi/members/statement/index.xhtml?l-id=enavi_all_glonavi_statement'),
  page.waitForLoadState('networkidle'),
]);

const availableOptionsTexts = await page.evaluate(() => {
      const cardSelect = <HTMLInputElement>document.querySelector('.stmt-head-regist-card__select__box');
      return Array.from(cardSelect.querySelectorAll('option'))
            .filter((option) => !option.innerText.includes('利用不可'))
            .filter((option) => option.selected === false)
            .map((option) => option.innerText);
        });
```

あとはダウンロードしましょう

```typescript:main.ts

// 初期の利用可能なカードの利用明細をダウンロード
        await downloadFunc();

        // その他利用可能なカードの利用明細をダウンロード
        for (const option of availableOptionsTexts) {
            // カードを選択
            await page.locator('.stmt-head-regist-card__select__box').selectOption(option);
            await page.waitForLoadState('networkidle');
            await downloadFunc();
        }

await browser.close();
```

## 最終コード

```typescript:main.ts
import { chromium } from 'playwright';
import * as path from 'path';
import * as dotenv from 'dotenv';

dotenv.config();

(async () => {
    try {
        const ID = process.env.ID;
        const PASS = process.env.PASS;
        if (!ID || !PASS) throw new Error("ID or PASS is not set.");

        // storageフォルダに保存
        const downloadDir = path.join(__dirname, 'storage');
        const browser = await chromium.launch({
            headless: true,
            downloadsPath: downloadDir,
            args: [
                '--incognito',
                '--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36'
            ]
        });
        const context = await browser.newContext({
            acceptDownloads: true,
        });
        const page = await context.newPage();

        await page.goto("https://www.rakuten-card.co.jp/e-navi/index.xhtml");
        await page.waitForTimeout(3000);

        await page.fill('input[name="u"]', ID);
        await page.fill('input[name="p"]', PASS);

        await Promise.all([
            page.click('input[id="loginButton"]'),
            page.waitForLoadState('networkidle'),
        ]);

        await page.waitForTimeout(2000);

        // 利用明細画面に遷移する
        await Promise.all([
            page.goto('https://www.rakuten-card.co.jp/e-navi/members/statement/index.xhtml?l-id=enavi_all_glonavi_statement'),
            page.waitForLoadState('networkidle'),
        ]);

        const availableOptionsTexts = await page.evaluate(() => {
            const cardSelect = <HTMLInputElement>document.querySelector('.stmt-head-regist-card__select__box');
            return Array.from(cardSelect.querySelectorAll('option'))
                .filter((option) => !option.innerText.includes('利用不可'))
                .filter((option) => option.selected === false)
                .map((option) => option.innerText);
        });

        // 利用明細CSVのダウンロード
        const downloadFunc = async () => {
            const downloadPromise = page.waitForEvent('download', {timeout: 10000});
            await page.click('.stmt-c-btn-dl.stmt-csv-btn');
            const download = await downloadPromise;
            // Wait for the download process to complete
            console.log(await download.path());
            // Save downloaded file somewhere
            const unixtime = Math.floor(new Date().getTime() / 1000);
            await download.saveAs(path.join(downloadDir, unixtime + 'rakuten-card.csv'));
        }

        // 初期の利用可能なカードの利用明細をダウンロード
        await downloadFunc();

        // その他利用可能なカードの利用明細をダウンロード
        for (const option of availableOptionsTexts) {
            // カードを選択
            await page.locator('.stmt-head-regist-card__select__box').selectOption(option);
            await page.waitForLoadState('networkidle');
            await downloadFunc();
        }

        await browser.close();

    } catch (err) {
        console.error(err);
    }
})();

```