第8章 ScrapyとHTTP

はじめに

ここではWebスクレイピングに必要なWebの知識をまとます。Webスクレイピングを行うためには、Scrapyの使い方やXpathでの値の指定の仕方以外に、多くのことを知っていないと効率よく、かつ、迷惑をかけずにスクレイピングすることはできないように思います。

HTTPについて

HTTPは、WebブラウザがWebサーバーと通信するためのプロトコルです。HTTPは、TCP/IPにおけるアプリケーションがやり取りを行う層のプロトコルで、トランスポート層の中に位置します。TCP/IPのことよりも、HTTPについて理解を深めることが、スクレイピングには大切かと思いますので、これ以降はHTTPについてまとめていきます。

いつも何のことなく使っているWebブラウザからです。Webブラウザといえば、Google chrome、Internet Explorer、Firefoxなどが有名なWebブラウザとしてあげられますが、これ何でしょうか。そもそもWebとは、World Wide Webを略して表現したもので、そのWebの中に、Webページがあります。WebページははHTML(HyperText Markup Language)という言語で構成されています。そのWebページを閲覧するために使うのものがブラウザです。

WikipediaのHTMLサンプルをお借りします。<xxx>hoge</xxx>という方法でテキストをマークアップしていくことでHTMLは構成されます。そのため、HTMLというのは、マークアップ言語とも呼ばれます。マークアップ言語は、人間が見やすいものではありません。

<!DOCTYPE html>
<html lang="ja">
 <head>
  <meta charset="UTF-8">
  <link rel="author" href="mailto:mail@example.com">
  <title lang="en">HyperText Markup Language - Wikipedia</title>
 </head>
 <body>
  <article>
   <h1 lang="en">HyperText Markup Language</h1>
   <p>HTMLは、<a href="http://ja.wikipedia.org/wiki/SGML">SGML</a>
      アプリケーションの一つで、ハイパーテキストを利用してワールド
      ワイドウェブ上で情報を発信するために作られ、
      ワールドワイドウェブの<strong>基幹的役割</strong>をなしている。
      情報を発信するための文書構造を定義するために使われ、
      ある程度機械が理解可能な言語で、
      写真の埋め込みや、フォームの作成、
      ハイパーテキストによるHTML間の連携が可能である。</p>
  </article>
 </body>
</html>

そのため、マークアップ言語を解釈して、表示し直してくれるのが、Webブラウザの機能です。そこで、ブラウザがWebサーバーから、HTMLを含め様々な情報を取得するために使う手段がHTTPです。

WebブラウザがHTTPというプロトコルに従って、Webサーバーに「リクエスト」を送ります。Webサーバーは、そのリクエストを受け取って、Webブラウザに「レスポンス」を返します。これがWebブラウザとWebサーバーの1つのやり取りで、その通信方法がHTTPです。

HTTPの内容

HTTPが実際にどのような内容をWebサーバーに送っているのか確認してみます。ターミナルからcurlコマンドを利用します。curlコマンドは、ファイルを送信または受信するためのコマンド。バージョンはこちら。

$ curl --version
curl 7.64.1 (x86_64-apple-darwin19.0) libcurl/7.64.1 (SecureTransport) LibreSSL/2.8.3 zlib/1.2.11 nghttp2/1.39.2
Release-Date: 2019-03-27
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtsp smb smbs smtp smtps telnet tftp 
Features: AsynchDNS GSS-API HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL UnixSockets

http://example.comにリクエストを送ってみます。>の部分がリクエストで、<の部分がレスポンスの部分です。なにやら沢山出力されたので、リクエストとレスポンスを分けて、内容を見ていきます。

$ curl --verbose http://example.com
*   Trying 93.184.216.34...
* TCP_NODELAY set
* Connected to example.com (93.184.216.34) port 80 (#0)
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 200 OK
< Age: 532987
< Cache-Control: max-age=604800
< Content-Type: text/html; charset=UTF-8
< Date: Thu, 28 May 2020 08:36:36 GMT
< Etag: "3147526947+ident"
< Expires: Thu, 04 Jun 2020 08:36:36 GMT
< Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
< Server: ECS (oxr/832B)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1256
< 
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
* Connection #0 to host example.com left intact
* Closing connection 0

HTTPリクエスト

まずはリクエストの部分です。4行目が「リクエスト行」で、5~7行目は「HTTPヘッダー」、8行目は「空白行」でヘッダーの終わりを意味し、POSTであれば、その下にwebサーバーにデータを送るための「メッセージボディ」が配置されます。

example.comに80番ポートから、GETというリクエストメソッドで接続を試みていることがわかります。HTTP1.1というのはHTTPのバージョンです。User-Agentというのは後で詳しく扱いますが、誰がしているのかを示します。ここでは、curlコマンドで行っていることがわかります。

*   Trying 93.184.216.34...
* TCP_NODELAY set
* Connected to example.com (93.184.216.34) port 80 (#0)
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/7.64.1
> Accept: */*
>

リクエストメソッドはGET以外にもよく使われるものとしてPOST、PUTなどがあります。コンテンツを取得する際に利用するものとして、GETとPOSTがありますが、GETはURLの後ろに情報を加える一方で、POSTはメッセージボディに情報を加えて通信します。基本的に見られて困る情報はPOSTで送ります。

メソッド

内容

GET

指定したURLのリソースをリクエスト。

HEAD

GETリクエストと同じレスポンスを求めますが、レスポンス本文はなく、ヘッダのみ。

POST

指定したリソースに情報を送信するためのメソッド。

PUT

指定したURLにリソースを保存。

DELETE

指定したURLのリソースを削除。

OPTIONS

指定したURLの通信オプションを示すために使用。

TRACE

サーバまでのネットワーク経路をチェック。

PATCH

リソースを部分的に変更。

CONNECT

TCPトンネルを接続する。暗号化したメッセージをプロキシサーバを経由して転送する際に用いる。

リクエストメソッドのPOST(Create)、GET(Read)、PUT(Update)、DELETE(Delete)の操作をまとめてCRUD操作として考えることをRESTアーキテクチャと呼びます。

HTTPレスポンス

次はHTTPレスポンスの中身を見ていきます。1行目が「ステータス行」で、2〜12行目が「HTTPヘッダー」で、13行目が「空白行」でヘッダーの終わりを示す。14行目が「メッセージボディ」でHTMLが返されます。

ステータスコードが200で成功したことを意味し、コンテンツの内容はHTMLで、キャラセットはutf8ということがわかります。日付、キャッシュ、コンテンツの長さなどが書かれています。

< HTTP/1.1 200 OK
< Age: 532987
< Cache-Control: max-age=604800
< Content-Type: text/html; charset=UTF-8
< Date: Thu, 28 May 2020 08:36:36 GMT
< Etag: "3147526947+ident"
< Expires: Thu, 04 Jun 2020 08:36:36 GMT
< Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
< Server: ECS (oxr/832B)
< Vary: Accept-Encoding
< X-Cache: HIT
< Content-Length: 1256
< 
<!doctype html>
<html>
<head>
【略】
</head>

<body>
【略】
</body>
</html>
* Connection #0 to host example.com left intact
* Closing connection 0

HTMLのデータを受け取り、Webブラウザは解析を行い、人間が見やすい形で表示します。その中に更に取得しなければ行けない情報(画像など)があると、再度、HTTPリクエストを送り、取得するように動きます。このHTTPリクエストとHTTPレスポンスを何度も行い、Webページを整えます。

このように、HTTPリクエストを受けつけ、HTTPレスポンスを返すことで、コンテンツを表示します。このように交互に通信を行うことを「同期通信」と呼びます。同期通信の欠点は、サーバーが処理している間は、待つしかなく、ページを更新するのに時間がかかります。これを解決するのがAjaxです。

Ajax(Asynchronous JavaScript + XML)は、Webブラウザ上で動作するjavascriptが直接Webサーバーと通信を行い、表示するHTMLを更新する仕組みです。この仕組を使うことで、HTTPレスポンスを待つ間に、レスポンスと関係ないところはjavascriptが更新する事ができるため、「非同期通信」が可能になり、ページの更新が速くなります。

HTTPステータスコード

HTTPレスポンスにはステータスコードが含まれます。先程の例では200が返されましたが、その他にも多くのステータスコードがあります。100番台から500番台までが使われます。

コード

メッセージ

内容

100

Continue

リクエストを継続していることを意味するステータス

200

Ok

リクエストが正常に完了していることを意味するステータス

301

Moved Permanently

リクエストされたコンテンツが移動してことを意味するステータス

302

Found

リクエストされたコンテンツが一時的に移動していることを意味するステータス

304

Not Modified

リクエストされたコンテンツが未更新。Webブラウザに一時的に保存されたコンテンツが表示される。

400

Bad Request

リクエストが不正であることを意味するステータス

401

Unauthorized

アクセス認証が必要な場合のステータス

403

Forbidden

アクセス禁止を意味するステータス

404

Not Found

リクエストされたコンテンツがないことを意味するステータス

405

Method Not Allowed

そのメソッドでのリクエストが許可されていないことを意味するステータス

406

Not Acceptable

ヘッダーで指定した言語でレスポンスができないことを意味するステータス

408

Request Timeout

クライアントとサーバー間の通信時間が設定時間をオーバーしたことを意味するステータス

500

Internal Server Error

リクエスト中にサーバーでエラーが発生していることを意味するステータス

501

Not Implemented

そのメソッドがサーバーでサポートされていないことを意味するステータス

502

Bad Gateway

ゲートウェイやプロキシとして動作しているサーバーが上位のサーバーから不正なレスポンスを受け取ったことを意味するステータス。

503

Service Unavailable

リクエストしたサーバーが一時的に停止していることを意味するステータス

Twitterの503はよく目にするけど、それを通知する画像がかわいい。

HTTPヘッダー

HTTPリクエスト、HTTPレスポンスのいずれもHTTPヘッダーを持ちます。ここには、詳細な情報をもたせることが可能です。1行の情報を「ヘッダーフィールド」と呼び、「フィールド名」と「フィールド値」で構成されます。

HTTPヘッダーは大きく4つのブロックで構成されます。「一般ヘッダー」「リクエストヘッダー」「レスポンスヘッダー」「エンティティヘッダー」です。詳細はMDNのサイトを参照。

まずは一般ヘッダーです。

項目

内容

Connection

リクエスト後のTCPコネクションの接続状態に関する通知

Date

HTTPメッセージが作成された日

Upgrade

HTTPのバージョンをアップデートするように通知

Cashe - Control

キャッシュの動作を指定

Pragma

データのキャッシュなどの追加情報

Transfer - Encoding

ボディで送るデータのエンコード方式

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

項目

内容

Accept

クライアントの受け入れ可能なコンテンツ

Accept - Charset

クライアントの受け入れ可能な文字セット

Accept - Encoding

クライアントの受け入れ可能な文字エンコード

Accept - Language

クライアントの受け入れ可能な言語

Cookie

Cookieをサーバーに送信する

From

リクエスト者のメールアドレス

Host

リクエスト先のサーバー

Referer

直前にリンクしていたURL

User - Agent

Webブラウザの情報

Proxy - Authorization

プロキシに対する認証情報

レスポンスヘッダーは下記のとおりです。

項目

内容

Age

ボディで送信するデータの経過秒数

Allow

URLに対して使用可能なメソッド

Proxy - Authenticate

プロキシでの認証が必要なことを意味する

Retry - After

次のリクエストを送るまでの待機時間

Location

リダイレクト先のWebページの情報

Server

Webサーバーの情報

Set-Cookie

クックーの情報

最後にエンティティヘッダーです。

項目

内容

Allow

利用可能なHTTPメソッドの情報

Content-Encoding

コンテンツのエンコード方式

Content-Language

コンテンツの言語

Content-Length

コンテンツのサイズ

Content-Type

コンテンツの種類

Expired

コンテンツの有効期限

Last-Modified

コンテンツの最終更新時刻

User-Agentについて、少し触れておきます。User-Agentはアクセスしているブラウザのバージョンや種類を示すもので、Scrapyでスクレイピングしている際に、同じUser-Agentだとアクセスできなくなることがります。そのような場合に、User-Agentを切り替える(偽造)してアクセスする必要があります。偽造という表現が良くないですが、問題のある行為ではありません。

例えば、Google Chromeの場合、下記のようなUser-Agentになります。What is my User Agent?で調べることができます。ちなみにグローバルIPアドレスはWhat is my ip?で調べることができます。

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36

Safariなどは下記のようになります。

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15

HTTPコネクション

「クライアントであるブラウザがリクエストを送り、サーバーがレスポンスを返す」というこのHTTPの一連のやり取りは、TCPが「コネクション」と呼ばれる通信経路を確立して行います。

HTTPのバージョンによって、コネクションの方法は異なります。HTTP/1.0以前は、WebブラウザがHTTPリクエストを送信するためにコネクションを確立していましたが、HTTP/1.1以降はHTTP KeepAlive方式が採用されており、リクエストごとにコネクションを確立する必要がなくなりました。

HTTP KeepAlive方式でない場合、HTMLの中に画像があった場合、HTTPリクエストを一度送り、コネクションを確立し、レスポンスを受け取って、コネクションを閉じて、画像を受け取るために再度コネクションを確立するという方法になります。

一方でHTTP KeepAlive方式だと、一度リクエストを送れば、コネクションを閉じずとも、HTTPリクエストとHTTPレスポンスのやり取りを行うことが可能なため、無駄な時間が発生しません。また、HTTPパイプラインという技術のおかげで、「コネクションの確立→リクエスト1→レスポンス1→リクエスト2→レスポンス2→コネクションの切断」というやり取りではなく、「コネクションの確立→リクエスト1→リクエスト2→レスポンス1→レスポンス2→コネクションの切断」といように、HTTPレスポンスを待たずともHTTPリクエストを送れるようになっています。

HTTP自体は非常にシンプルな通信プロトコルですが、弱点があります。それは「ステートレス」なやりとりであるということです。これは文字通り、「状態を保持しない」通信ということで、ブラウザとのHTTP通信を考えると、サーバー側は毎回別人としてクライアントを扱うことになります。

一方で、「ステートフル」なやりとりを行うことも可能です。この場合、言葉の通り、「状態を保持する」通信であるため、ブラウザとのHTTP通信を考えると、サーバー側はクライアントが誰なのかを識別して、通信を行うことになります。この仕組みであれば、ECサイトでは、買い物かごに商品を入れてから離脱して再度、ECサイトに戻ってきた場合でも、買い物かごにいれた商品は、そのまま買い物かごに入ったままの状態を保つことが可能です。

HTTPはステートレスなプロトコルであるため、ステートフルなやりとりを実現するためにHTTP cookieというデータを用いて通信しています。

HTTPとWebアプリケーション

Webブラウザ上で機能するアプリケーションのことをWebアプリケーションと呼びます。Webアプリケーションについても、少しばかり裏側の仕組みおさらいしておきます。

Webアプリケーションは3層アーキテクチャと呼ばれる構造になっています。3層とは、「プレゼンテーション層」「アプリケーション層」「データ層」の3つです。

プレゼンテーション層は、ユーザーのインターフェースを担い操作などを受けつけ、Webサーバーが裏側で機能します。WebサーバーはWebブラウザからHTTPリクエストを受け取ると、静的コンテンツや動的コンテンツをHTTPレスポンスとして返します。このサーバーがダウンすると、HTTPリクエストを受け付けるサーバーがなくなるので、サービスが停止します。そうならなように、冗長化という構成をとります。冗長化は、Webサーバーを複数起動し、HTTPリクエストをさばく仕組みです。

アプリケーション層は、業務的な処理を担い、プレゼンテーション層からの操作を処理します。裏側ではアプリケーションサーバーが機能します。アプリケーションサーバーは、Webサーバーからの指示をデータベースサーバーに問い合わせて、データを返す役割です。先程でてきたセッションIDの管理もアプリケーションサーバーの役割です。

データ層は、アプリケーションサーバーからのデータに関する処理を担います。裏側では、データベースサーバーが機能します。データベースサーバーはアプリケーションサーバからの依頼に基づいてデータベースから情報を返します。Webサーバーと同じく、冗長化構成を取ります。冗長化の方法としては、「ミラーリング」、「レプリケーション」「シェアードディスク」があります。「ミラーリング」、「レプリケーション」もマスターサーバーがあり、そこでの更新をミラーサーバーないしスレーブサーバー転送します。ミラーリングは即時更新しますが、レプリケーションは任意のタイミングで更新します。

このような構成でWebアプリケーションは機能しますが、各サーバーへの負荷を下げる仕組みとして、「キャッシュサーバー」というものがあります。キャッシュとは「HTTPリクエストに対するHTTPレスポンスを記憶する」ことです。キャッシュにも種類があり、コンテンツをキャッシュするものは「コンテンツキャッシュ」と呼ばれ、DBサーバーへの検索クエリをキャッシュするものは「クエリキャッシュ」と呼ばれます。

コンテンツキャッシュサーバーは、WebブラウザとWebサーバーとの間に配置され、エリキャッシュサーバーはアプリケーションサーバーとデータベース・サーバーとの間に配置されます。

他にもサーバーへの負荷を下げる手段として、ファイアウォールがあります。ファイアウォールはどちらかというセキュリティの観点から説明されることが多いですが、通信を遮断するものなので、ここで合わせてまとめておきます。

一般的に用いられているのは、パケットフィルタ型のファイアウォールです。これはIPアドレスとポート番号をチェックし、通信の遮断・許可を行います。誰でも使用できるWebシステムであれば、WebサーバーアクセスにするIPアドレスを限定することは難しいので、HTTPの80番ポートとHTTPSの443番ポートだけを許可することになります。

MySQLなどのデータベースサーバーであれば、ポート番号は3306で、アクセスするIPアドレスを限定することで、アクセスできる人を限定したりすることが多いのかもしれません。

HTTPと認証

ここでは、基本的な認証の種類についてまとめていきます。認証がかかっているサイトはクローラーを走らせるのは好ましくないように思えます。認証がかかるということは、その先にあるのは個人情報や秘密情報であるためです。そのため、アカウントアグリゲーションサービスかなんかを作るとかになると、規約の範囲内で行うことになるかと思います。

Webページにアクセスすると、ポップアップと同時にアカウントとパスワードが求められる認証方式がHTTP認証です。HTTP認証の中でも広く使われる方法が、ベーシック認証です。ベーシック認証がかかっているURLにリクエストを送ると、401が返ってきます。

# -I:HTTPレスポンスヘッダーの取得
$ curl -I http://pythonscraping.com/pages/auth/login.php 
HTTP/1.1 401 Unauthorized
Date: Sun, 31 May 2020 03:08:50 GMT
Server: Apache
WWW-Authenticate: Basic realm="My Realm"
Content-Type: text/html; charset=UTF-8

ベーシック認証のアカウントとパスワードを送信する方法は-u id:passでおくります。ログインできているようです。

➜ curl -u 'tabaka:awsedrft' http://pythonscraping.com/pages/auth/login.php 
<p>Hello tabaka.</p><p>You entered awsedrft as your password.</p>~ 

他にもフォームベース認証というものがあります。これは、名前の通りで、ログインフォームからIDとパスを認証する方法です。例えば、Githubはフォーム認証です。

フォームに該当する部分のHTMLを抜き出すと、このようになっています。1行目のform要素のaction属性、method属性を確認すると、method="post"action="/session"というパスに情報を送ることがわかります。

<form action="/session" accept-charset="UTF-8" method="post"><input type="hidden" data-csrf="true" name="authenticity_token" value="****************==" />      <input type="hidden" name="ga_id" class="js-octo-ga-id-input">
      <div class="auth-form-header p-0">
        <h1>Sign in to GitHub</h1>
      </div>


      <div id="js-flash-container">


  <template class="js-flash-template">
    <div class="flash flash-full  js-flash-template-container">
  <div class="container-lg px-2" >
    <button class="flash-close js-flash-close" type="button" aria-label="Dismiss this message">
      <svg class="octicon octicon-x" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z"></path></svg>
    </button>
    
      <div class="js-flash-template-message"></div>

  </div>
</div>
  </template>
</div>


      <div class="flash js-transform-notice" hidden>
        <button class="flash-close js-flash-close" type="button" aria-label="Dismiss this message">
          <svg class="octicon octicon-x" viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z"></path></svg>
        </button>
      </div>

      <div class="auth-form-body mt-3">

        <label for="login_field">
          Username or email address
        </label>
        <input type="text" name="login" id="login_field" class="form-control input-block" tabindex="1" autocapitalize="off" autocorrect="off" autocomplete="username" autofocus="autofocus" />

        <label for="password">
          Password <a class="label-link" href="/password_reset">Forgot password?</a>
        </label>
        <input type="password" name="password" id="password" class="form-control form-control input-block" tabindex="2" autocomplete="current-password" />
        <input type="hidden" class="js-webauthn-support" name="webauthn-support" value="unknown">
        <input type="hidden" class="js-webauthn-iuvpaa-support" name="webauthn-iuvpaa-support" value="unknown">
        <input type="hidden" name="return_to" id="return_to" class="form-control" />
        <input class="form-control" type="text" name="required_field_2cad" hidden="hidden" />
<input class="form-control" type="hidden" name="timestamp" value="1590894924840" />
<input class="form-control" type="hidden" name="timestamp_secret" value="35403dd16a13de0a200b8bedb46b64b4309326083002f2b7b73c3f5bfd96ce33" />

        <input type="submit" name="commit" value="Sign in" tabindex="3" class="btn btn-primary btn-block" data-disable-with="Signing in…" />
      </div>
</form>

Chromeの検証ツールを使うとname="login"name="password"で値を送っていることがわかります。また、authenticity_tokenは毎回更新する度に変更されるので、クローラーを走らせるには、この値を毎回スクレイピングしてから、ログインIDとパスワードを送って認証する必要があります。

Scrapyでやってみます。ここでは、食べログのサイトを参考にしてみます。scrapy shellでサイトにアクセスします。

$ scrapy shell "https://tabelog.com/"

In [1]: from scrapy.http import FormRequest, Request

当たり前ですが、トップページがそのまま返ってきます。なので、検索条件を指定してフォームリクエストを行うことで、自分がほしいページが返ってくるようにします。まずは、どのような値でリクエストを送ればよいのか調べます。ここでは、「渋谷駅」「焼肉」「2020年7月1日」「20:00」「10名」という条件のレスポンスがほしいので、これを指定して、「検索」ボタンをおして、検証ツールからどのような値が必要なのか調べます。

画像の右下を見る限り、LstKindからkey_datatypeまで様々な値を送っていることがわかります。なので、これを丸ごとコピーして辞書型に直し、FormRequest()でリクエストします。そうすることで、検索条件を指定したあとのページをレスポンスとして受け取ることが可能です。検索ボタンのアクションはaction="https://tabelog.com/rst/rstsearch/"となっているので、それをフォームリクエストでは使用します。

scrapy shell "https://tabelog.com/"

from scrapy.http import FormRequest, Request

data = {
    "LstKind": "1",
    "voluntary_search": "1",
    "lid": "yoyaku-search_header",
    "sa": "渋谷駅",
    "sk": "焼肉",
    "vac_net": "1",
    "search_date": "2020/7/1(水)",
    "svd": "20200701",
    "svt": "2000",
    "svps": "10",
    "hfc": "1",
    "area_datatype": "RailroadStation",
    "area_id": "4698",
    "key_datatype": "keyword",
}

page = FormRequest('https://tabelog.com/rst/rstsearch/',
                   formdata = data)


fetch(page)        
view(response)

画像はview(response)の結果です。指定した条件でページを取得できています。食べログにはありませんでしたが、フォームリクエストでは、不正な攻撃を避けるために、Githubにあったような更新のたびに変更される値も必要になる場合があるので、それも必要であれば、スクレイピングして更新のたびに最新の値がとれるようにして、FormRequest()でリクエストします。

HTTP CookieはどのようにHTTP通信に関わってくるのかを見ていきます。WebブラウザからHTTPリクエストを送った際に、サーバーはHTTPレスポンスと合わせて、ブラウザに保存してほしい情報としてHTTP Cookieも送ります。

ECサイトであれば、HTTPリクエストを送った際に、HTTPレスポンスをブラウザを識別するためにブラウザにHTTP Cookieも送ります。その後、ブラウザがそのECサイトにアクセスする際に、保存しているHTTP Cookieを送ることで、サーバー側では、誰がアクセスしてきたのかを識別します。

実際の通信では、HTTPヘッダーに、HTTPレスポンスをブラウザに送る時は、Set-CookieヘッダーにHTTP Cookieの情報を付与します。Set-Cookieは複数あっても問題ありません。また、属性はセミコロンで区切られます。HTTPリクエスト側は、Cookieヘッダーに情報を含めることになります。ECサイトなどでは有効期限を定めたセッションCookieが一般的に用いられます。ちなみにcurlコマンドのヘッダーオプション-Hを使うことで、Cookieを送信できます。

$ curl --verbose -H 'Cookie: name=scrapy; ver=111' http://example.com
*   Trying 93.184.216.34...
* TCP_NODELAY set
* Connected to example.com (93.184.216.34) port 80 (#0)
> GET / HTTP/1.1
> Host: example.com
> User-Agent: curl/7.64.1
> Accept: */*
> Cookie: name=scrapy; ver=111

HTTP Cookieと関連して、Webブラウザとサーバーの一連のやり取りの流れを「セッション」と呼びます。セッション管理のために、WebサーバーはHTTP CookieにセッションIDを付与することでセッション管理を行います。そのため、リクエストを送る際にセッションIDも送ることで、サーバー側は「セッション」を識別していきます。またセッションにおける最小粒度のアクションを「トランザクション」と呼びます。複数のトランザクションが時系列的に集まったものがセッションと言えるかもしれません。

HTTPフォームとCookieについてもう少し深堀りしてまとめておきます。ここでは、Practical Web Scraping for Data Science: Best Practices and Examples with Pythonで使われているサンプルページをお借りして、Cookieの理解を深めます。requests.Session()を使えば簡単ではありますが、ここでは使わず動かしながら確認します。

このページはログインすることで秘密のページへアクセスできるような仕組みになっています。ログインしていない状態だと、秘密のページにはアクセスできません。必要なCookie情報がないため、サーバーが秘密のページのURLを表示しません。

import requests

url = "http://www.webscrapingfordatascience.com/cookielogin/secret.php"
r = requests.get(url)
print(r.text)

# Hmm... it seems you are not logged in

そのため、秘密のページへアクセスするために、ログインしてCookieを取得してから、そのCookieを使って秘密のページへHTTPリクエストを送信します。そうすることで、秘密のページへのアクセスを行います。

import requests

# Login
url = "http://www.webscrapingfordatascience.com/cookielogin/"
data_post = {"username": "testuser", "password": "password"}
r = requests.post(url, data = data_post)

# Cookieを取得して、secretページにアクセス
my_cookie = r.cookies
url = "http://www.webscrapingfordatascience.com/cookielogin/secret.php"
r = requests.get(url, cookies = my_cookie)

print(r.text)

# This is a secret code: 1234

このようなパターンであれば問題ないのですが、ログインが成功すれば同時に秘密のページへリダイレクトされる場合はどうすればよいでしょうか。

このような場合は、post()allow_redirects引数を設定して、リダイレクトされないようにします。

import requests

# Login
url = "http://www.webscrapingfordatascience.com/redirlogin/"
data_post = {"username": "testuser", "password": "password"}
r = requests.post(url, data = data_post, allow_redirects=False)

# Cookieを取得して、secretページにアクセス
my_cookie = r.cookies
url = "http://www.webscrapingfordatascience.com/redirlogin/secret.php"
r = requests.get(url, cookies = my_cookie)

print(r.text)
# This is a secret code: 1234

最後の例として、ログインページにアクセスしたかどうか、ログイン後にCookieのセッションIDを変更するようなサイトの場合を見ていきます。このような場合でも、Cookieを取得して、Cookiを使ってログイン、ログイン後に変わるCookieを取得して、Cookieを更新してから、秘密のページへアクセスすれば、秘密のページへアクセス可能です。

import requests

# Formを取得
url = "http://www.webscrapingfordatascience.com/trickylogin/"
r = requests.post(url)
my_cookie = r.cookies
print(my_cookie)

# 先程のCookieを使ってpostを実行
r = requests.post(url,
                  params={"p": "login"},
                  data = {"username": "testuser", "password": "password"},
                  allow_redirects = False,
                  cookies = my_cookie)

# Cookieを更新する
my_cookie = r.cookies
print(my_cookie)

# 秘密のページへアクセス
r = requests.get(url, params={"p": "protected"}, cookies = my_cookie)
print(r.text)


# <RequestsCookieJar[<Cookie PHPSESSID=8u3j3mh4sq286755uuo59gaj83 for www.webscrapingfordatascience.com/>]>
# <RequestsCookieJar[<Cookie PHPSESSID=ak6p8cpip0c0i9ee4vsmn6fet5 for www.webscrapingfordatascience.com/>]>
# Here is your secret code: 3838.

このような感じでCookieを処理すればよいのですが、Scrapyで同じように処理しようとすると、うまくいきません。こちらの動画で詳しく説明されているので、これをなぞってみます。動画に沿って、まずはHTTPリクエストの内容を可視化できるRequestBin.comを使って確認してみます。

サイトにアクセスして開発者ツールからヘッダーの情報をコピーして、

これを使ってリクエストを送ります。コードは下記のとおりです。

import requests

# custom headers
headers = {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "accept-encoding": "gzip, deflate, br",
    "accept-language": "ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6",
    "cache-control": "max-age=0",
    "cookie": "_gcl_au=1.1.2053070165.1592979574; _ga=GA1.2.1180196223.1592979574; _gid=GA1.2.1504925251.1592979574; _lr_uf_-dhjtrz=a5956b98-e3b3-41d8-ab3a-3f9c182bd391; _hjid=ec0ef6a8-a0b1-431d-abe1-01397ae26645; ki_r=; _uetsid=11adf75b-e96c-d7d7-9ea2-dfa133728451; _uetvid=d6cd5e1d-cdde-40f2-01b6-f60ff4361f57; _gat_UA-128559955-1=1; _lr_tabs_-dhjtrz%2Fpd={%22sessionID%22:0%2C%22recordingID%22:%224-be179f28-1ed2-4d5d-81ff-e6d0d409ac9a%22%2C%22lastActivity%22:1592986151170}; _lr_hb_-dhjtrz%2Fpd={%22heartbeat%22:1592986151172}; ki_t=1592979575629%3B1592979575629%3B1592986152027%3B1%3B5; amplitude_id_eadd7e2135597c308ef5d9db3651c843requestbin.com=eyJkZXZpY2VJZCI6IjdjYTczN2U3LTkyMzMtNDllOC1iOGUxLTEwMGRiZGM1YWEwM1IiLCJ1c2VySWQiOm51bGwsIm9wdE91dCI6ZmFsc2UsInNlc3Npb25JZCI6MTU5Mjk4NTczNzcyNiwibGFzdEV2ZW50VGltZSI6MTU5Mjk4NjE1MjEyNywiZXZlbnRJZCI6NiwiaWRlbnRpZnlJZCI6MCwic2VxdWVuY2VOdW1iZXIiOjZ9",
    "if-modified-since": "Wed, 01 Apr 2020 23:15:20 GMT",
    "sec-fetch-dest": "document",
    "sec-fetch-mode": "navigate",
    "sec-fetch-site": "none",
    "sec-fetch-user": "?1",
    "upgrade-insecure-requests": "1",
    "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36"
}

# requestbin.com URL
url = 'https://enldafevk2rhb.x.pipedream.net/'

# make HTTP GET request to requestbin.com
response = requests.get(url, headers=headers)
print(response.text)

実行してTRUEが返ってくると、画像のようにリクエストの内容が表示されます。画像を見ると、Cookieが問題なくリクエストとともに送られていることがわかります。

$ python3 lesson.py
{"success":true}

Scrapyで同じようにやってみます。

import scrapy
from scrapy.crawler import CrawlerProcess

# spider class
class HeadersCookies(scrapy.Spider):
    name = "headerscookies"

    url = "https://enldafevk2rhb.x.pipedream.net/"

    # custom headers
    headers = {
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
        "accept-encoding": "gzip, deflate, br",
        "accept-language": "ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6",
        "cache-control": "max-age=0",
        "cookie": "_gcl_au=1.1.2053070165.1592979574; _ga=GA1.2.1180196223.1592979574; _gid=GA1.2.1504925251.1592979574; _lr_uf_-dhjtrz=a5956b98-e3b3-41d8-ab3a-3f9c182bd391; _hjid=ec0ef6a8-a0b1-431d-abe1-01397ae26645; ki_r=; _uetsid=11adf75b-e96c-d7d7-9ea2-dfa133728451; _uetvid=d6cd5e1d-cdde-40f2-01b6-f60ff4361f57; _gat_UA-128559955-1=1; _lr_tabs_-dhjtrz%2Fpd={%22sessionID%22:0%2C%22recordingID%22:%224-be179f28-1ed2-4d5d-81ff-e6d0d409ac9a%22%2C%22lastActivity%22:1592986151170}; _lr_hb_-dhjtrz%2Fpd={%22heartbeat%22:1592986151172}; ki_t=1592979575629%3B1592979575629%3B1592986152027%3B1%3B5; amplitude_id_eadd7e2135597c308ef5d9db3651c843requestbin.com=eyJkZXZpY2VJZCI6IjdjYTczN2U3LTkyMzMtNDllOC1iOGUxLTEwMGRiZGM1YWEwM1IiLCJ1c2VySWQiOm51bGwsIm9wdE91dCI6ZmFsc2UsInNlc3Npb25JZCI6MTU5Mjk4NTczNzcyNiwibGFzdEV2ZW50VGltZSI6MTU5Mjk4NjE1MjEyNywiZXZlbnRJZCI6NiwiaWRlbnRpZnlJZCI6MCwic2VxdWVuY2VOdW1iZXIiOjZ9",
        "if-modified-since": "Wed, 01 Apr 2020 23:15:20 GMT",
        "sec-fetch-dest": "document",
        "sec-fetch-mode": "navigate",
        "sec-fetch-site": "none",
        "sec-fetch-user": "?1",
        "upgrade-insecure-requests": "1",
        "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36"
    }

    def start_requests(self):
        # make HTTP GET request to requestbin.com
        yield scrapy.Request(
            url=self.url,
            headers=self.headers,
            callback=self.parse
        )

    def parse(self, response):
        print(response.text)


if __name__ == "__main__":
    process = CrawlerProcess()
    process.crawl(HeadersCookies)
    process.start()

画像を見てもらうとわかるのですが、Cookieがありません。これはScrapyのstart_requestsでは、辞書形式でCookieを送る必要があるためです。

そのためCookieを取り出して、セミコロンでCookieは繋がれるので、それ使って分割して辞書に変換します。

import scrapy
from scrapy.crawler import CrawlerProcess

# spider class
class HeadersCookies(scrapy.Spider):
    name = "headerscookies"

    url = "https://enldafevk2rhb.x.pipedream.net/"

    # custom headers
    headers = {
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
        "accept-encoding": "gzip, deflate, br",
        "accept-language": "ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6",
        "cache-control": "max-age=0",
        "if-modified-since": "Wed, 01 Apr 2020 23:15:20 GMT",
        "sec-fetch-dest": "document",
        "sec-fetch-mode": "navigate",
        "sec-fetch-site": "none",
        "sec-fetch-user": "?1",
        "upgrade-insecure-requests": "1",
        "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36"
    }

    raw_cookie = "_gcl_au=1.1.2053070165.1592979574; _ga=GA1.2.1180196223.1592979574; _gid=GA1.2.1504925251.1592979574; _lr_uf_-dhjtrz=a5956b98-e3b3-41d8-ab3a-3f9c182bd391; _hjid=ec0ef6a8-a0b1-431d-abe1-01397ae26645; ki_r=; _uetsid=11adf75b-e96c-d7d7-9ea2-dfa133728451; _uetvid=d6cd5e1d-cdde-40f2-01b6-f60ff4361f57; _gat_UA-128559955-1=1; _lr_tabs_-dhjtrz%2Fpd={%22sessionID%22:0%2C%22recordingID%22:%224-be179f28-1ed2-4d5d-81ff-e6d0d409ac9a%22%2C%22lastActivity%22:1592986151170}; _lr_hb_-dhjtrz%2Fpd={%22heartbeat%22:1592986151172}; ki_t=1592979575629%3B1592979575629%3B1592986152027%3B1%3B5; amplitude_id_eadd7e2135597c308ef5d9db3651c843requestbin.com=eyJkZXZpY2VJZCI6IjdjYTczN2U3LTkyMzMtNDllOC1iOGUxLTEwMGRiZGM1YWEwM1IiLCJ1c2VySWQiOm51bGwsIm9wdE91dCI6ZmFsc2UsInNlc3Npb25JZCI6MTU5Mjk4NTczNzcyNiwibGFzdEV2ZW50VGltZSI6MTU5Mjk4NjE1MjEyNywiZXZlbnRJZCI6NiwiaWRlbnRpZnlJZCI6MCwic2VxdWVuY2VOdW1iZXIiOjZ9"

    # parse cookies
    def parse_cookies(self, raw_cookies):
        cookies = {}
        for cookie in raw_cookies.split('; '):
            try:
                key = cookie.split('=')[0]
                val = cookie.split('=')[1]
                cookies[key] = val
            except:
                pass

        return cookies

    def start_requests(self):
        # make HTTP GET request to requestbin.com
        yield scrapy.Request(
            url = self.url,
            headers = self.headers,
            cookies = self.parse_cookies(self.raw_cookie),
            callback = self.parse
        )

    def parse(self, response):
        print(response.text)


if __name__ == "__main__":
    process = CrawlerProcess()
    process.crawl(HeadersCookies)
    process.start()

リクエストの内容をみるとCookieも問題なくリクエストに含んで送れています。

最終更新