GAS でログイン必須なサイトでも簡単に Web スクレイピングできるライブラリを作った

# 概要

Google Apps Script （以下、 GAS ）でログインが必要なサイトに対してスクレイピングを行うライブラリ（`AutoLoginFetchApp` クラス）を作ったので、その紹介をします。

https://github.com/nimzo6689/gas-AutoLoginFetchApp

スクレイピングする際は、robots.txt や利用規約などをご確認いただき、自己責任でお願いします。

# 背景

GAS でスクレイピングを行うためには、 `UrlFetchApp.fetch()` を使って HTTP リクエストを送ることで実現できます。
しかし、ログインが必要なサイトに対して行う場合、ログインページのフォーム要素周辺の解読や Cookie の保持などが必要でした。

スクレイピングのコーディングをする際、これらの作業がけっこう面倒になりがちだったので、そういった作業を一括でスキップできるようなライブラリを作れないかと考えたのがきっかけです。

# 課題と解決策

今回作成するライブラリで扱う課題とその解決策は以下のようにしました。

<dl>
  <dt>1．Cookieの保存</dt>
  <dd>
  GAS の UserCache にて保存。<br>
  最大有効期限である 6 時間まで保存することで、それ以内の通信時は同じ Cookie を使いまわす。<br>
  Cookie の MaxAge や Expires が 6 時間未満であればその値に従って保存時間を調整。
  </dd>
  <dt>2．リクエスト頻度</dt>
  <dd>
  ログインを必要としているサイトはスクレイピングによる負荷をあまり想定できていないかもなので、指定された秒数以内で追加でリクエストできないようにスリープする処理を入れました。デフォルトでは 0 秒なので、スリープされません。<br>
  オプションの引数で頻度を調整可能にしています。</dd>
  <dt>3．通信エラー時のリトライ回数</dt>
  <dd>
  通信エラー対策でデフォルトではリトライされません。<br>
  また、一時的なネットワークの不安定を考慮して、次のリトライまでに 2, 4, 8, ... 秒とスリープ時間を増やすようにしました。<br>
  </dd>
  <dt>4．<code>UrlFetchApp.fetch()</code> の第 2 引数に追加する拡張パラメータを保持する</dt>
  <dd>
  例えば、毎回 <code>muteHttpExceptions</code> を <code>true</code> に設定したい場合、 <code>AutoLoginFetchApp</code> のインスタンス内でそれを保持し、 <code>fetch</code> ごとにその設定内容を適用するようにしました。
  </dd>
  <dt>5．ログイン時に form の action や input 要素などの情報を自動で取得する</dt>
  <dd>
  <code>AutoLoginFetchApp</code> のコンストラクタの第 1 引数に指定されたログインページの内容をライブラリ内でスクレイピングして取得するようにしています。
  value 属性値の中身を上書きしたい場合は第 2 引数、追加してほしくない input 要素がある場合は第 3 引数の customOptions でそれぞれ制御可能です。
  </dd>
</dl>

# 使い方

今回は Apps Script のブラウザ IDE での利用を前提に解説します。

## ブラウザ IDE でのセットアップ方法

Script ID はこちらです。
`193oTq1hqBLv_A_4vJBAL1tFR6ACM2DoBzVUaIVGcHRpa4rk1-jpN4KR8`

「ライブラリの追加」から以下のような設定で追加してください。
バージョンは現時点の最新版を使っていただくことをお勧めします。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114728/0e5dd1e8-f484-94b9-61ef-e44ccc67908a.png">

また、 CSS セレクタで HTML を解析したい場合は、[cheeriogs](https://github.com/tani/cheeriogs) も一緒にライブラリに登録しておくといいと思います。

## 基本的な使い方

サンプルコードはこちらです。

```javascript
function main() {
  // インスタンスを設定（ログイン先のURL、認証情報）
  const client = new AutoLoginFetchApp.AutoLoginFetchApp('https://localhost/login.html', {
    username: 'test_username',
    password: 'test_password',
  });
  // ログインが必要な URL にアクセス。
  const response = client.fetch('https://localhost/path/to/target-page.html');

  // Cheerio で HTML をパースする。
  const $ = Cheerio.load(response.getContentText());
  // ログインが成功したかどうかを判定。（サイトによって確認方法は異なる）
  console.log(/Logout/.test($('html').text() ? 'Logged in successful.' : 'Failed to log in.'));
}
```

`UrlFetchApp.fetch()` の代わりに `AutoLoginFetchApp.fetch()` を使ってリクエストを送信します。
`fetch()` のシグネチャは第 2 引数まで両者とも全く同じにしました。

ログインに関する設定は `AutoLoginFetchApp` のコンストラクタで渡します。

第１引数（`url`）にログインページの URL を、
第２引数（`params`）にログイン時にリクエストされる認証情報に関するパラメータです。

認証情報のパラメータに何を指定すべきかはブラウザから確認します。
例えば、 eclipse.org の場合は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114728/69895810-d861-da6a-c01c-6d6964d541a0.png)

↑ ユーザ名が name、パスワードは pass であることがわかる。

```javascript
function main() {
  const url = 'https://accounts.eclipse.org/user/login';

  const client = new AutoLoginFetchApp.AutoLoginFetchApp(url, {
    name: 'test_username',
    pass: 'test_password'
  });
  const response = client.fetch('https://accounts.eclipse.org/user');

  const $ = Cheerio.load(response.getContentText());
  console.log($('#dropdownMenu').text().split('\n')[0]);
  //=> アカウント名が出力される。
}
```

また、実際にログイン時のリクエストが送信されるタイミングはコンストラクタで初期化されたタイミングではなく、
最初に `fetch()` が呼び出されたタイミングになります。

### 補足：ログイン時にリクエストされるパラメータについて

`AutoLoginFetchApp` コンストラクタの第２引数で指定するログイン時のパラメータはログインページの form タグ内の input 要素の name と value を上書きします。
そのため、仮に password という name の input 要素があり、それを指定しなかった場合、 password パラメータは空白でサーバーに送信されます。（ default 値が特に入っていなければ）

## `AutoLoginFetchApp` の挙動をカスタマイズしたい場合

コンストラクタの第３引数にて細かい挙動の制御をできるようにしました。

:::note info
初投稿時からパラメータ名やデフォルト値を変更しました。
:::

| プロパティ名 | 形式 | デフォルト値 | 説明 |
|---|---|---|---|
| maxRetryCount | 1 ~ | 1 | 通信が失敗した場合の最大再試行回数です。 |
| leastIntervalMills | 1 ~ 300000 | 0 | 前回の要求から、次の要求を送信するまでの最小間隔です。これは、スクレイピングによって対象サイトに過負荷をかけないようにするための対策です。 |
| loginForm | CSS セレクタ | `form` | ログインフォームの要素を選択する CSS セレクタです。 |
| loginFormInput | CSS セレクタ | `form input` | input 要素を選択する CSS セレクタです。 loginForm が指定されていれば、その子の input 要素のみ抽出されます。 |
| requestOptions | `UrlFetchApp.fetch` の第 2 引数の option | `UrlFetchApp` に準拠 | 詳細は公式ドキュメント <a href="https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app?hl=ja#advanced-parameters">Class UrlFetchApp - 拡張パラメータ</a> を参照ください。 |
| logging | boolean | false | HTTP 通信時の request と response 、その他エラー発生時のログ出力を有効にするかどうかのフラグです。|

以下、上記のオプションを設定した実装例です。

```javascript
function main() {
  const client = new AutoLoginFetchApp.AutoLoginFetchApp('https://localhost/login.html', {
    username: 'test_username',
    password: 'test_password',
  }, {
    maxRetryCount: 3, // 3 回リトライする
    leastIntervalMills: 1000, // リクエスト間隔を最低 1 秒にする
    loginForm: 'form[name="Form1"]', // form 要素が 1 ページに 2 つ以上ある場合
    loginFormInput: 'input:not([name="btnClear"])', // 「ログイン」「クリア」ボタンが 2 つ存在している場合など,
    requestOptions: {
      muteHttpExceptions: true // 400, 500 エラー時も throw を発生させない
    },
    logging: true
  });
```

# さいごに

使ってみた感想としては、ログインが必要なサイトのスクレイピングのハードルが下がり、時間や手間が大幅に節約できて快適でした。
ただ、サイトによってはログイン通信周りが変わった仕様になっていて、まだまだ改良の余地は大いにありそうです。

Google Apps Script は、日々の生活のこまごまとしたタスクの自動化に使いやすいと思うので、ログインが必要なサイトのスクレイピングを必要とする人の役に立てれば幸いです。