Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
126
Help us understand the problem. What is going on with this article?
@shikumiya_hata

誰も教えてくれなかったログインの話 〜GASスクレイピング編(ID/パスワード認証)

はじめに

「GASでスクレイピングしたいんだけれど、ログインできなくて...」
「ログインできず、結局Seleniumを使うことに...」

といったお悩み、ありませんか?

本記事では実業務で今日から使える、ID/パスワード方式のログイン画面を突破するための知識と技術について解説します。

まずはスクレイピングに必要な周辺知識を、そして会計freeeのログインを例に実装に必要な分析を、
最後にGASでの実装手順について書いていきます。

具体的には、デベロッパーツールでWebブラウザが行なっている通信の内容を解析し、
それと同じことをGASで実装して再現する
という内容になります。
この知識を抑えておけば、他の言語や技術でも応用が可能です。

注意事項

(2021年6月 追記)
本記事のビュー数やリアクションが増えてきました。ありがとうございます。
スクレイピング技術が広範になるに連れ、スクレイピングにまつわる問題も顕著になって来ましたので追記させていただきます。

スクレイピングするにあたっての注意事項として以下の記事を紹介させていただきます。
必ず読んでください。「知らなかった」では済まされない事態になり得るからです。

当記事は悪質なスクレイピング・Webサービス提供者への迷惑行為を助長するものではありません。

本記事のWebやHTTPの仕組みを読むことで理解ができるかと思いますが、スクレイピングとは特定のWebページ(サーバー)にHTTPリクエストを送受信することで成り立つものです。

サーバーはリクエストを無限に受けられるわけでは決してありません。短時間で大量にアクセスし続けると相手のサーバーがダウンします。また、Webサービスが従量課金系のインフラサービスを利用している場合、リクエスト数や通信量に応じて費用が変動します。相手に迷惑がかかる可能性があることを常に念頭に置いてください。

リクエストを送る場合には必ずウェイト(待機時間)を入れるなどして間隔を空けて実行してください。

GASのウェイト処理
Utilities.sleep(1000) // 1秒待機

Webの仕組み

スクレイピングに必要な周辺知識について解説して行きます。
今回の実装の手法がなぜ成り立つのかがわかるようになる範囲で説明したいと思います。

Webブラウザが行っていること

普段使っているChromeやSafariなどのWebブラウザですが、
あるWebページへブラウザからアクセスする場合、
ざっくり以下のようなことを行なっています。

  1. ブラウザにURLを入力すると、インターネット上にリクエスト(要求)を飛ばす
  2. URL内のドメインに一致するインターネット上のサーバーを探しに行く
  3. サーバーが見つかったら、URLの残りのパスからWebアプリケーションを呼び出す
  4. Webアプリケーションは処理結果のレスポンス(応答)を返却する
  5. リクエスト元へレスポンスが返却される
  6. ブラウザはレスポンスを受け取り、レスポンスを解析する
  7. (必要に応じて、CookieをPC上に保存する)
  8. ブラウザのレンダリングエンジンがHTMLの解析結果としてブラウザ上に描画する

image.png

この時に、HTTPと呼ばれる通信手順を用いて、リクエストを送信したり、
レスポンスを送信したりしています。

HTTPとは

HTTPとは通信手順(プロトコル)のひとつです。
これはサーバー同士がネットワークを介して相互に通信するための約束事を定めています。

HTTPは、GETやPOSTなどのリクエストに対して、ひとつのレスポンスを返します。
そしてそのやり取りの内容は、「文字列のかたまり」です。

こちらの記事が良くまとまっていると思います。
「HTTPとは何か?」を完全に理解する〜分かったフリの脱却〜

リクエストは3層で構成されたテキストです。

  • リクエストライン
  • ヘッダー
  • ボディ

レスポンスも同様に3層で構成されたテキストです。

  • ステータスライン
  • ヘッダー
  • ボディ

つまり、Webブラウザは内部ではテキストのやり取り(通信)を行なっているんです。
従って、このHTTPの構成と一致するテキストを送受信できれば、
Webブラウザの通信と同じ通信を再現することが可能
なのです。

GASにはUrlFetchApp#fetch(url, options)というAPIがありますが、
これがまさに、HTTPリクエストを飛ばして、レスポンスを返却するという機能なんです。

スクレイピングとは、「HTTPのリクエストとレスポンスを解析して再現する」という作業と言えます。

手順

ログイン突破するための手順を説明して行きます。

必要なもの

  • Webブラウザー(Chromeがオススメ)
  • GASスクリプトエディタ
  • 対象サービスのログイン情報(ID/パスワード)

本記事ではChromeを前提とします。

ログインフローについて

手順の前に、基本的なログインフローについて図示しておきます。(図1)

通常、ブラウザでは以下のような操作が行われます。

(1) ログイン画面を表示
(2) ID/パスワードを入力し、ログインボタンでログイン
(3) (どこかへリダイレクト) ※ある場合
(4) ログイン後の画面が表示される

次より説明する実装手順では、これと同様のリクエストをGASから送信する流れとなります。

図1. 基本的なログインフロー
image.png

※ 例として以下のURLにリクエストすると仮定しています。

  • ログイン画面:[GET] /login
  • ログイン処理:[POST] /login
  • リダイレクト:[GET] /redirect
  • ログイン後:[GET] /top

実装手順

実際の手順です。
手順6〜7については、リダイレクトを挟むサービスの場合のみ対象です。

※ 本記事では、具体的なサンプルとしてクラウド会計ソフトの「freee」を対象に解説して行きます。

1. ログイン画面を表示
2. デベロッパーツールを表示 ※ブラウザによって名称が異なります
3. 画面からログイン
4. ログイン時のリクエスト情報を参照
5. ログインのリクエスト送信を実装
6. リダイレクトのリクエスト情報を参照
7. リダイレクトのリクエスト送信を実装
8. ダッシュボード画面表示のリクエスト情報を参照
9. ダッシュボード画面表示のリクエスト送信を実装
10. ダッシュボード画面のHTMLが返却されればログイン突破成功

手順詳解

リクエストのフロー図です。
この全体像に沿って説明して行きます。

Qiita_HowToLogin.png

1. ログイン画面を表示

まず、対象となるサービスのログイン画面をブラウザで表示します。

ログイン画面 | freee

2. デベロッパーツールを表示

ブラウザのデベロッパーツールを表示し、
「Network」タブを選択しておきます。

image.png

3. 画面からログイン

ログイン画面でメールアドレス(ID)とパスワードを入力します。
ログインが成功すると、ダッシュボード画面が表示されます。

image.png

4. ログイン時のリクエスト情報を参照

ダッシュボード画面が表示されたら、デベロッパーツールのNetworkの中に、
ログイン画面からのリクエスト情報が表示されます。(図2)

図2. freeeログインからダッシュボードまでのリクエスト
image.png

図2より、3つのリクエストが発行されていることがわかります。

1. 「accounting」をリクエスト >>> 「after_login」へリダイレクト(302)
2. 「after_login」をリクエスト >>> 「secure.freee.co.jp」へリダイレクト(302)
3. 「secure.freee.co.jp」をリクエスト >>> ダッシュボード表示(200)

といった流れになっています。

まずはログインのリクエストである「accounting」のリクエスト情報を採集します。
「accounting」を選択します。

image.png

基本情報の分析

この情報から、以下のことがわかります。これをリクエストの「基本情報」とします。

・ https://accounts.secure.freee.co.jp/login/accountingにリクエストした
・ POSTメソッドでリクエストした
・ 302のステータスコードが返却された

リクエストヘッダーの分析

次に、「Request Headers」を開きます。
リクエストヘッダーの情報が表示されます。

※ セキュリティ上の理由からcookieの掲載は省かせていただいております

image.png
image.png

実は、この情報が全て必要というわけではありません。
特定の情報さえあれば大丈夫です。
freeeのログインに必要なヘッダー情報を記載します。
※ サービスの仕様によって必須となるヘッダー情報は変わります

・ cookie
 クッキーの情報。

・ user-agent
 送信元のユーザーエージェント。この場合はChromeブラウザ。
 GASの場合は独自のユーザーエージェントとなってしまうため、
 ブラウザからのアクセスであることを偽装するために、この情報を使用する。

Cookieの分析

Cookieの詳解は割愛しますが、簡易に言うと...
CookieとはPCのディスク上に保存されているWebサイトの情報です。
例えば一度ログインし、再度アクセスしてもログインなしでページが開けるのは、
Webサイトの認証に関する情報や有効期限が記録されているからです。

freeeもCookieに対してセッションやトークンの情報を保存しています。
また、特定のCookieがリクエストの必須項目となっているため、これを利用します。

なお、GASの場合はCookieを保持しておく機構が存在しないので、
スクリプトの変数上に保持しておき、毎回のリクエストに付与する必要があります。

デベロッパーツールの「Cookies」を開き、「Request Cookies」を参照します。
「Headers」 > 「Request Headers」 > 「cookie」
と同じです。(見やすいのは前者)

image.png

ここでは後者を掲載します。さて、たくさん登場しました。

\ ポイント /
・ リクエストで送信したCookieを取得する
・ どれが必要かは消去法、サービスの仕様によって必須の値が異なる

消去法とは言え、ノーヒントはさすがに厳しいので、ヒントを探します。
概ね「session」や「token」といったキーワードが多いように思います。
また、「GA」が付与されているものはGoogle Analyticsで使用しているCookieである可能性が高いです。

幸いにも、freeeの場合は「accounting」のレスポンスCookieの値にヒントがありました。

image.png

3つまで絞られました。あとは地道な作業です。
トライアンドエラーで必須項目を探します。

結果として「_freee-accounts_session」が必要でした。
(これはダッシュボードへのリクエストが行えた時点でわかりました)

リクエストパラメータの分析

デベロッパーツールの「Headers」を開き、「Form Data」を参照します。
ログイン時に送信されたID/パスワードなどのリクエストパラメータが表示されます。(図3)

図3. ログイン時のリクエストパラメータ
image.png

ログインボタンを押下した際、ログインフォームから送信される情報は、
図3の情報であることがわかりました。

\ Tips /
Chromeのデベロッパーツールにはリクエストからcurlコマンドを生成する機能もありますので、
curlに慣れている方は、そちらで分析を行うのも良いです。

5. ログインのリクエスト送信を実装

手順4まででログインのリクエストに必要な情報が揃いましたので、
リクエスト処理を実装します。

ログイン画面表示のリクエスト

ログイン処理のリクエストに必要なCookieを取得するため、ログイン画面表示のリクエストを行い、
レスポンスからCookieを取得します。

  let response, cookies, content, $, headers, payload, options

  // ----------
  // ログインページを開く(GET)

  // リクエスト送信
  response = UrlFetchApp.fetch('https://accounts.secure.freee.co.jp/login/accounting?a=false&e=0&o=true')

  // レスポンスヘッダーからCookieを取得
  cookies = response.getHeaders()["Set-Cookie"];
  // Cookieから_freee-accounts_sessionを取得
  let cookie_freee_accounts_session = CookieUtil.getValue(cookies, '_freee-accounts_session')

  // コンテンツ(ログインページのHTML)を取得
  content = response.getContentText("UTF-8")

※ 以下のUtilityを実装して使用しています。

utils.js
/**
 * Cookieのユーティリティクラス
 */
class CookieUtil {
  /**
   * 値を抽出
   * @param {string} cookie Cookieデータ("name=value;...")
   * @return {string} value
   */
  static getValue(cookies, key) {
    const cookiesArray = cookies.split(';');

    for(const c of cookiesArray){
      const cArray = c.split('=');
      if(cArray[0] == key){
        return cArray[1]
      }
    }

    return false
  }
}

ログイン処理のリクエスト

レスポンスのHTMLからログイン処理に必要なパラメータを取得します。
今回はcheerioを使用します。

cheerioをGAS用にライブラリ化し、公開してます↓
スクリプトID: 13KJxU8q0ZYmZXyQswU2HrkQX-yXlgnlJ3BVzsKrS69oaE4FcViPRFPZb

\ ポイント /
・ followRedirectsオプションはfalseに
 UrlFetchApp#fetch()のoptionsパラメータにfollowRedirectsの設定をします。
 これをfalseにしておかないと、リダイレクト処理が実行され、ログイン画面へ戻ることとなり、
 ログイン処理のレスポンスではなく、ログイン画面表示のレスポンスが取得されてしまいます。

ログイン処理(フォーム送信)を実装します。

// 上記からの続き >>>

  $ = cheerio.load(content)
  // 以下のパラメータはログインフォーム内に記載されているため取得
  const token = $('[name="authenticity_token"]').val()
  const fp = $('[name="fp"]').val()

  // ----------
  // ログインフォーム送信(POST)
  headers = { // リクエストヘッダー
    'cookie': '_freee-accounts_session=' + cookie_freee_accounts_session + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  payload = { // リクエストパラメータ
    'utf8': '',
    'authenticity_token': token,
    'email': 'メールアドレス',
    'password': 'パスワード',
    'fp': fp,
    'o': true,
    'a': false,
    'e': 0
  }
  options = {
    'method': 'post', // POSTメソッド
    'headers': headers,
    'payload': payload,
    'followRedirects': false // リダイレクト処理を抑止しておく
  }
  response = UrlFetchApp.fetch('https://accounts.secure.freee.co.jp/login/accounting', options)

  content = response.getContentText("UTF-8")

ここまで実行し、リダイレクトのHTMLがcontentに返却されれば成功です。
成功すると次のURLがhref属性にセットされています。

成功時
<html><body>You are being <a href="https://secure.freee.co.jp/users/after_login">redirected</a>.</body></html>

失敗するとログイン画面表示のURLがhref属性にセットされます。
つまり、ログイン画面に戻されます。

失敗時
<html><body>You are being <a href="https://accounts.secure.freee.co.jp/login/accounting?a=false&amp;e=0.0&amp;o=true">redirected</a>.</body></html>

6. リダイレクトのリクエスト情報を参照

リダイレクトのリクエスト情報を採集します。
要領はこれまでと同じです。

デベロッパーツールで「after_login」を開きます。

image.png

基本情報の分析

基本情報です。

・ https://secure.freee.co.jp/users/after_loginにリクエストした
・ GETメソッドでリクエストした
・ 302のステータスコードが返却された(さらにリダイレクト)

リクエストヘッダーの分析

リクエストヘッダーです。

・ cookie
・ user-agent

Cookieの分析

リダイレクトのリクエスト情報を分析します。

image.png

以下の2つに絞れそうです。

・ _session_id
・ _auth_session_id

結果、ここでのCookieは「_auth_session_id」のみが必要でした。

リクエストパラメータの分析

リクエストパラメータはありません。

7. リダイレクトのリクエスト送信を実装

手順6の情報とともに、リダイレクトのリクエスト処理を実装します。

  // _auth_session_idを取得し次のリクエストにセット
  let cookie_auth_session_id

  // 前回のレスポンスからCookieの情報を取得
  cookies = response.getAllHeaders()["Set-Cookie"];
  for (const c in cookies) {
    const cookie = cookies[c]

    if (CookieUtil.getValue(cookie, '_auth_session_id')) {
      cookie_auth_session_id = CookieUtil.getValue(cookie, '_auth_session_id')
    }
  }

  // ----------
  // リダイレクト(GET)
  headers = {
    'cookie': '_auth_session_id=' + cookie_auth_session_id + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  options = {
    'method': 'get',
    'headers': headers,
    'followRedirects': false
  }
  response = UrlFetchApp.fetch('https://secure.freee.co.jp/users/after_login', options)

  // 結果を確認
  console.log(response.getResponseCode())
  console.log(response.getContentText('UTF-8'))

  content = response.getContentText("UTF-8")

レスポンスから結果を確認しましょう。ダッシュボードのURLがhref属性に付与されていれば成功です。
(失敗するとログイン画面に戻されます)

成功時
<html><body>You are being <a href="https://secure.freee.co.jp/">redirected</a>.</body></html>
失敗時
<html><body>You are being <a href="https://secure.freee.co.jp/users/login">redirected</a>.</body></html>

8. ダッシュボード画面表示のリクエスト情報を参照

ダッシュボード画面表示のリクエスト情報を採集します。

デベロッパーツールで「secure.freee.co.jp」を開きます。

image.png

基本情報の分析

基本情報です。

・ https://secure.freee.co.jpにリクエストした
・ GETメソッドでリクエストした
・ 200のステータスコードが返却された

リクエストヘッダーの分析

リクエストヘッダーです。

・ cookie
・ user-agent

Cookieの分析

リダイレクトのリクエスト情報を分析します。

image.png

ここも2つに絞れそうです。

・ _session_id
・ _auth_session_id

結果、ここでのCookieは「_auth_session_id」のみが必要でした。

リクエストパラメータの分析

リクエストパラメータはありません。

9. ダッシュボード画面表示のリクエスト送信を実装

リクエスト処理を実装します。

  // ----------
  // ログイン後のページ(GET)
  headers = {
    'cookie': '_auth_session_id=' + cookie_auth_session_id + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  options = {
    'method': 'get',
    'headers': headers,
    'followRedirects': false,
    'muteHttpExceptions': false
  }
  response = UrlFetchApp.fetch('https://secure.freee.co.jp', options)

  content = response.getContentText("UTF-8")

10. ダッシュボード画面のHTMLが返却されればログイン突破成功

contentにダッシュボード画面のHTMLが代入されていればログインは成功です。
ただ、HTML自体が長いので、今回はログアウトリンクの有無で判定することとしました。

  $ = cheerio.load(content)

  if ($('.logout-link').length > 0) {
    // ログアウトリンクがある:成功
    console.log('success')
  } else {
    // ログアウトリンクがない:失敗
    console.log('failed')
  }

完成したスクリプト

スクリプトの全文です。

freee.js
// 13KJxU8q0ZYmZXyQswU2HrkQX-yXlgnlJ3BVzsKrS69oaE4FcViPRFPZb
const cheerio = libpack.cheerio()

/**
 * Cookieのユーティリティクラス
 */
class CookieUtil {
  /**
   * 値を抽出
   * @param {string} cookie Cookieデータ("name=value;...")
   * @return {string} value
   */
  static getValue(cookies, key) {
    const cookiesArray = cookies.split(';');

    for(const c of cookiesArray){
      const cArray = c.split('=');
      if(cArray[0] == key){
        return cArray[1]
      }
    }

    return false
  }
}

/**
 * freeeへのログイン処理
 */
function login() {
  const me = this

  let response, cookies, content, $, headers, payload, options

  // ----------
  // ログインページを開く(GET)
  response = UrlFetchApp.fetch('https://accounts.secure.freee.co.jp/login/accounting?a=false&e=0&o=true')
  //console.log(response.getResponseCode())
  //console.log(response.getAllHeaders())

  cookies = response.getHeaders()["Set-Cookie"];
  //console.log(cookies)

  let cookie_freee_accounts_session = CookieUtil.getValue(cookies, '_freee-accounts_session')

  content = response.getContentText("UTF-8")

  $ = cheerio.load(content)
  const token = $('[name="authenticity_token"]').val()
  const fp = $('[name="fp"]').val()

  Utilities.sleep(1000)

  // ----------
  // ログインフォーム送信(POST)
  headers = {
    'cookie': '_freee-accounts_session=' + cookie_freee_accounts_session + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  payload = {
    'utf8': '',
    'authenticity_token': token,
    'email': 'メールアドレス',
    'password': 'パスワード',
    'fp': fp,
    'o': true,
    'a': false,
    'e': 0
  }
  options = {
    'method': 'post',
    'headers': headers,
    'payload': payload,
    'followRedirects': false
  }
  response = UrlFetchApp.fetch('https://accounts.secure.freee.co.jp/login/accounting', options)
  console.log(response.getResponseCode())
  //console.log(response.getAllHeaders())

  content = response.getContentText("UTF-8")

  // _auth_session_idを取得し次のリクエストにセット
  let cookie_auth_session_id

  cookies = response.getAllHeaders()["Set-Cookie"];
  for (const c in cookies) {
    const cookie = cookies[c]

    if (CookieUtil.getValue(cookie, '_auth_session_id')) {
      cookie_auth_session_id = CookieUtil.getValue(cookie, '_auth_session_id')
    }
  }

  Utilities.sleep(1000)

  // ----------
  // リダイレクト(GET)
  headers = {
    'cookie': '_auth_session_id=' + cookie_auth_session_id + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  options = {
    'method': 'get',
    'headers': headers,
    'followRedirects': false
  }
  response = UrlFetchApp.fetch('https://secure.freee.co.jp/users/after_login', options)

  console.log(response.getResponseCode())
  //console.log(response.getAllHeaders())
  console.log(response.getContentText('UTF-8'))

  content = response.getContentText("UTF-8")
  //console.log(content)

  cookies = response.getHeaders()["Set-Cookie"];
  //console.log(cookies)

  Utilities.sleep(1000)

  // ----------
  // ログイン後のページ(GET)
  headers = {
    'cookie': '_auth_session_id=' + cookie_auth_session_id + ';',
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36'
  }
  options = {
    'method': 'get',
    'headers': headers,
    'followRedirects': false,
    'muteHttpExceptions': false
  }
  response = UrlFetchApp.fetch('https://secure.freee.co.jp', options)
  content = response.getContentText("UTF-8")
  //console.log(content)

  $ = cheerio.load(content)

  if ($('.logout-link').length > 0) {
    console.log('success')
  } else {
    console.log('failed')
  }
}

おわりに

ここまで読めば冒頭で記述したことの意味を理解していただけたかなと思います。

デベロッパーツールでWebブラウザが行なっている通信の内容を解析し、
それと同じことをGASで実装して再現する
ということです。

そこさえ抑えておけばスクレイピングがきっと捗ることでしょう。

本記事が皆様の一助になれば幸いです。
最後までお付き合いいただき、ありがとうございました。


弊社は業務効率化・自動化など、仕組みで解決するお手伝いをさせていただいております。
お仕事のご依頼はコチラ↓までお願いいたします。

株式会社シクミヤ
note: Visionary Base編集部
Twitter: @shikumiya_hata

126
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
126
Help us understand the problem. What is going on with this article?