photo credit: the local eye sore : man scraping illegal billboard, castro, san francisco (2014) via photopin (license)
こんにちは。リスペクトの木村です。
今回は「スクレイピング」についての話題をお送りします。
スクレイピングとは
ウェブスクレイピング(Web scraping)とは、ウェブサイトから情報を抽出するコンピュータソフトウェア技術のこと。ウェブ・クローラー(Web crawler) あるいはウェブ・スパイダー(Web spider)とも呼ばれる。
ウェブスクレイピング – Wikipediaより
要するに、「APIを利用せずにWebページのHTMLデータを収集して、データを抽出したり整形する技術」の事を指します。
収集方法も様々で、最近ではkimonoのようなサービスもありますが、個人的にはスクリプトをごりごりと書いて構築しています。
利用しているライブラリ
ネイティブのDOMで取得する方法もありますが、基本的には「PHP Simple HTML DOM Parser」を利用しています。
jQueryライクにパースできるので、非常に使いやすいです。必要なライブラリが1ファイルというのもポイント。
ただし、「あくまでもパーサー」なので、リンククリックを再現したりCookieの送受信をしたい場合は、プログラム側で工夫する必要があります。
そのため、使い方によっては難易度が非常に高くなったりしますが、ちょこっとスクレイピングしたいような時はこれで十分です。
このライブラリに限った話ではありませんが、取得したデータを全て読み込んだ状態で解析を行うため、ページによってはメモリを大量に使用します。そのため、メモリ管理に気を付けなければなりません。
定期的にメモリをクリアしていたとしてもPHPごと落ちるという場合もあるので気を付ける必要があります。
最近は、Goutteなんてのもあるようです。
スクレイピングを行うにあたって
作業前に確認するべき点や、作業中に気を付ける必要がある点がいくつかかあります。
確認している事
スクレイピングをせずともデータ取得できるかの確認や、スクレイピングの技術で取得できそうかといった調査を、作業前にある程度しておく事が大切です。
スクレイピングはあくまでも最終手段という風に捕らえていて、正規の手順があればそれに従うべきです。
私が作業前に確認しているのは、主に次の3点になります。
- 本当にAPIが存在しないか
- 対象となるページは直接アクセス可能か
- 取得後の用途に問題は無いか
それぞれ、一つ一つ解説します。
本当にAPIが存在しないか
有償無償を問わずサイト側がAPIを提供していて、そこから十分にデータが取れるのであればそれを利用するに越した事はありません。
ただ、APIがあってもデータが不十分だったり精度があまり良くないという場合があるため、その場合はスクレイピングの利用を検討します。
対象となるページは直接アクセス可能か
GETで参照するだけや、POSTで必要なパラメータを送れば取得できる場合は大分楽です。
リファラやCookieを見ている場合は、多少手間ですが工夫すれば何とかなります。
しかし、その他の原因で弾かれている場合は、サイト側で何かしらの対策をしている場合が多いので、どうしても取得したければそれを回避する必要があります。この場合、容易ではないことがほとんどです・・・。
取得後の用途に問題は無いか
取得後の用途については十分注意する必要があります。
何故なら、取得先のデータは自分以外の著作物にあたり、著作権法に抵触しないように考慮する必要があるためです。
文化庁のサイトを確認すると、どんな場合に著作物が自由に使えるかが表記されています。
スクレイピングに限った話に絞ると、主に認められているのは次の2パターンです。
個人的な利用であれば「私的利用」になるため問題ありませんが、例えば社内での利用目的で取得する場合は特に注意する必要があります。
また、原則として出所の明示をする必要もあります。(第48条)
たとえ元々は「情報解析」の目的で取得したデータであっても、「情報解析」以外の用途(第49条の五)で利用した場合は、許可されない複製を行ったとみなされることがありますので、合わせて注意が必要です。
「社内の利用であれば第47条の6ではないか?」という意見もありそうですが、「公衆からの求めに応じ~その結果を提供することを業として行う者」とあり、社内での提供は特定少数への提供にあたるため、この場合は第47条の6よりは第47条の7を考慮すべきではないかと解釈しています。
気を付けている事
ではスクレイピングでデータを取得しましょう、となった時に、構築中や作業している中でも気を付けるべきポイントがいくつか存在します。
「可能な限り迷惑をかけないようにする」かつ「手っ取り早くデータを取得する」という所を両立させるのは非常に難しく、勘や経験が頼りになってしまう場合も少なくありません。
実際に取得時に気を付けている主なポイントは次の3点です。
- 取得時に内外にかかる負荷は問題無いか
- 取得までに余計なステップを踏んでいないか
- 取得に失敗する状況が起きないか
ここも一つ一つ解説します。
取得時に内外にかかる負荷は問題無いか
何も対策をしないと、1秒間に何十回や何百回とアクセスする事になるので相当な負荷になります。
対策としては十分な間隔を置く(取得の度にsleep()を挟む、など)必要がありますし、後述するrobots.txtの内容によってはそれに合わせて調整します。
また、データの保存にRDBMSなどを利用している場合は、その方面についての負荷についても十分な注意が必要です。
特に、一時的なものだからといってインデックスも付けずにやっていると、あっという間に負荷が掛かってしまい、アラートのメールが大量に飛んでくる事態になってしまいます。
取得までに余計なステップを踏んでいないか
例えば、2つめの詳細ページを見に行く場合に、一覧→詳細(1)→詳細(2)と踏んでいるとします。
しかし、一覧の時点で詳細(2)へのリンクが張られていたり、詳細(1)のリンク先からある程度推測できたりすれば詳細(1)へのアクセスはいりません。
処理にかかる時間が削減されるので、確認時は取得手順を隅から隅まで確認するようにしています。
取得に失敗する状況が起きないか
深夜や土日にバッチ処理で回している場合が多いと思いますので、失敗した場合に悲しいことになります。
プログラム自体のエラーが起きないようにするのはもちろんですが、取得失敗時のリカバリー策もある程度検討しておく必要があります。
ログによるチェック
特にオススメなのが、実行ログを実行後にメールその他で通知するという事です。
cronで回すような場合は、通知先を自分のアドレスにしておけば、エラーも含めて出力をそのままメールで送信してくれるのでエラーの発生タイミングや原因に気付きやすくなります。
設定例
設定例といっても、そんなに難しいものではありません。
スクリプトはCronで定期的に動作させているため、cronの「MAILTO=」にメールアドレスを設定し、実行結果を全てメールで通知するようにしています。
スクリプト内では、実行結果や通知したい内容をechoやprintを使って出力する事で、出力された内容がメールにて通知されます。
また、PHPのdisplay_errorsを有効にしておいたり、error_reportingをE_ALLにしておけば、実行中に出たエラーや例外も合わせて通知されます。
こうすると、例えばMySQLサーバへの接続に失敗している時も気付きやすくなります。
以前、AWSのEC2で構築していたサーバから、MySQLサーバへの接続に失敗しているという通知があったため調べてみると、スクリプトを置いているEC2のIPアドレス範囲が変わっていて、MySQLサーバ側に設定していたファイアウォールが通過できなくなっていた、という事がありました。
おおまかな流れ
これでひと通りの事前確認が終わりました。すべて問題が無かった所で、実際の調査や取得を行います。
ページのデータを取得してくる処理自体はどのサイトが対象であっても大体同じですが、サイト毎に異なる仕様を吸収する処理を追加する必要がありますので事前調査は重要です。
調査
利用規約の確認
大抵のサイトには利用規約が存在するため、まず最初に確認しておきます。
場所はヘルプやFAQからリンクが張ってあったり、フッターのナビゲーション部分にリンクがあったりとサイトによって様々です。
サイトによってはスクレイピングのような行為を明示的に禁止している場合があるため確認が必要です。
また、明示されていない場合でも電話やメールで問い合わせてみるというのが紳士的かと思われます。
(相手側がこういった技術に詳しくないと、話が通じなくて中々難しい場合もありますが・・・。)
ここで合意を取れれば次に進みます。
robots.txtの確認
robots.txtを開き、その内容を確認します。サイトルートにあるので、すぐに確認できます。
色々指定できる所がありますが、確認すべきは次の2つです。
- Crawl-Delay … クロールの時間間隔を指定します。
- Disallow … クロールされたくないページやディレクトリを指定します。
- Allow … Disallowにされたディレクトリの下の階層で、クロールさせたいページやディレクトリを指定します。
Crawl-Delayがある場合は、取得する間隔を指定された数値に合わせ、取得したいページがDisallowに指定されている場合は取得を行わない方が無難かと思われます。(Allowにある場合は別)
robots.txtの内容とスクレイピング先のURLを照らし合わせて、Disallowに指定されていないかや、DisallowになっていたとしてもAllowに指定されているかどうかを確認します。
Crawl-Delayの指定がある場合は数値を記録しておき、実際に取得処理を構築するタイミングで、指定された間隔で取得する処理にします。
URL構造の確認
取得先のURL構造を確認します。
以下にブログなどの、一覧ページと詳細ページを持つサイトでの例を挙げます。
一覧ページ
一覧ページの場合は、次のような所に気を付けて確認しています。
- ページ番号やページ数を、どの部分で参照しているか
- 全てのURLパラメーターは本当に必要か
例えば、「/search?utf8=?&hoge=huga&q=testtest&page=1」というURLだとすると・・・
- utf8=?やhoge=hugaは必要か
- q=は検索文字列なので必要なはず
- page=の後がページ番号
という点について確認します。
実際に番号や文字列を変えてみたり、パラメーターを削ってアクセスしてみて、問題無いようであればターゲットのURLを確定します。
ここでなるべくシンプルなURLにできれば今後の調整が楽になりますが、シンプルになりすぎてエラーになったり想定外のデータまで落ちてくるという場合もあるので、シンプルにこだわりすぎる必要はありません。
詳細ページ
詳細ページの場合は、次のような感じです。
- アクセスする場合の固有Noは存在するか
- 固有Noは連番かランダムか
- 他に変化している部分は無いか
大体固有の番号が割り当てられている場合がほとんどなので、それに向かってアクセスしてみます。
例えば、「/article/1234」というURLであれば、後半の「1234」の部分が固有Noであるというのが想像できます。
後は、検索結果の個別のリンク先を確認し、想像通りかどうかを確認します。
希に「/article/hokkaido/1234」のように、「1234」だけではなく「hokkaido」も変化している場合があるため、ここにも気を付ける必要があります。
アクセス方法確認
構造が確認できたら、そのURLにはどうやってアクセスする必要があるのかを確認します。
単にGETでアクセスするだけであれば良いのですが、次のような工夫が必要な場合もあります。
- 何かしらのパラメーターをPOST
- リファラが必要
- Cookieが必要
また、ブラウザでアクセスすると普通に見られるけど、いざプログラムからだと見られない・・・という場合があるのでここはプログラムでさくっと書いて確認します。
PHPなら、file_get_contentsでヘッダの付与が行えるので、POSTやリファラ、Cookieの設定も可能です。
取得したいデータまでのDOMツリー確認
アクセスできるようになったら、取得したいデータまではどんな構造を辿れば良いかを確認します。
わざわざHTMLを追っても良いのですが、大体のブラウザに開発者向けのモードやツールが搭載されているので、それを利用すると便利です。
ChromeならDeveloperToolsのElementsパネルでDOMツリーが確認できるため、大体の推測は可能です。
上の画像の、「html ~ h1.entry-title」と書いてある部分がDOMツリーを可視化したものです。
例えば、「find(‘html body h2’)」と書くと、<h2>
が複数存在する場合は全ての要素に当てはまってしまいますが、その<h2>
にIDが付いていれば、「find(‘html body h2#id’)」というように指定すると、目的のタグの情報を取得できます。
もちろん、「idではなくclassが付いていて、1回の指定で目的のタグを取得できない」という場合もありますが、その場合は、全ての<h2>
を取得してループで探索するなど、地道に探す事になります。
取得
目星も付いて必要な情報も揃ったら、あとはスクリプトを回して取得するのみです。
PHP Simple HTML DOM Parserのマニュアルを片手に、ガシガシ取得しましょう。
取得の際は、User-Agentを調整した上で取得します。
具体的には、下記のように予めUser-Agentヘッダを用意しておき、それが含まれているストリームコンテキストリソースを利用して取得します。
$context_param = array( 'http' => array( 'header' => 'User-Agent: HogeBot/1.0(+hogehoge@hugahuga.com)' ) ); $contect = stream_context_create($context_param); $data = file_get_html('http://www.hogehoge.com/list', false, $context);
処理的には、
- ヘッダー用文字列を用意して、連絡先としてメールアドレスを追加
- ストリームコンテキストリソースに変換して
- それを利用して取得
という流れです。
「User-Agent:」以降には「Bot名」と「メールアドレス」を含ませておきます。特に、メールアドレスは問題があった時の窓口となりますので、忘れずに。
コツ
UAの変更の他にも、取得時のコツが何点かありますのでご紹介します。
一見簡単そうですが、下記の点に気を付けるとよりスムーズに取得できると思います。
- find()の結果の中で「何番目かの特定の順番の結果」が欲しいという場合は、第2引数に欲しい位置(0からスタート)を入力するとその結果が取れます。
「find(‘h2#title’)」の最初の要素が欲しいという場合は「find(‘h2#title’, 0)」といった具合です。
-1にすると、最後の要素が取れます。 - 狙った要素が取れない場合があるので、その場合は範囲を広げる必要があります。
- 前後の要素を探したい場合はnext_sibling()/prev_sibling()を使います。
- ライブラリのソースを見ると分かりますが、file_get_htmlは内部でfile_get_contentsを使っているので、stream_context_createの返り値(ストリームコンテキストリソース)を指定できます。
指定したい場合は、第3引数に指定します。(第2引数は、特別な指定をしない場合はfalse)
ここまでが、一通り実施している手順になります。
後はデータを煮るなり焼くなり、求められているように調理しましょう。
例
では、今までの中で若干特殊だったという例をいくつかご紹介します。
URLや各種コードはあくまでも例です。
その1
事例
- ブラウザで見た場合と、file_get_htmlで取得した場合のソースが異なる
解決方法
色々な事例が考えられますが、一番多いのはUser-Agentで分けている場合です。
取得の部分でUAを変更しましたが、そこを既存のブラウザのUAに偽装して取得するように変更します。
$context_param = array( 'http' => array( 'header' => 'User-Agent: Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0; HogeBot/1.0; hogehoge@hugahuga.com)' ) ); $contect = stream_context_create($context_param); $data = file_get_html('http://www.hogehoge.com/list', false, $context);
上記ですとIEに偽装しています。UAはここやここに色々と載っていますので希望するブラウザのものを取得し、追加でBot名と連絡先のメールアドレスを追加しています。
User-Agentを他のブラウザのように変更すれば、スマホやガラケーになりすます事も可能です。
その2
事例
- ページャーのリンクを見てみた所、
javascript:changePage(2)
のようにJavaScriptの関数になっていた - 関数を追うと、どうやらPOSTでページを切り替える必要がありそう
解決方法
とりあえず、ページャーで使っている関数を追いかけてみます。
ページに埋め込まれている場合がありますが、外部JSの場合もあるので注意が必要です。
function changePage(int) { document.navi.page.value = int; document.navi.submit(); }
実際に追ってみると上記の関数である事が分かったので、次の事が推測できます。
- 渡された引数をpageにセットしている
- submit()を実行して、ページを切り替えている
次は、関数内で使用しているnaviを追ってみます。
<form>
内で使用されている、という所に気を付けて追えば、汎用的な名前であってもすぐ見つけられるはずです。
<form name="navi" method="POST" action="/search?action=search&ab=cd"> <input type="hidden" name="page" value="1"> (省略) <input type="hidden" name="flag" value=""> </form>
上記のような何も入ってないフォームが見つかりました。確認すると、pageの他にも色々なフラグが入っています。
その結果、次の要素が確定します。
- リクエストの送信先は「/search?action=search&ab=cd」
- メソッドはPOST
- pageにはページ番号が入る
後は、他のhiddenで隠れている要素を全て送信する必要があるかどうかを検証するだけです。
POSTでリクエストを飛ばす方法は、こちらが参考になります。
必要なパラメーターやURLが確定すれば、後はどんどん取得するのみです。
おわりに
スクレイピング回りの様々な話をお送りしました。
法的にグレーゾーンとなる場合があるせいかあまり情報も見当たらなかったので、今回持っている中で可能な限りのノウハウを公開する事にしましたが参考になりましたでしょうか。
気を付けなければならない所が多々ありますが、スクレイピング処理は色んな技術を試せたりプログラミングの練習や復習にもなる良い教材でもあります。
これからスクレイピングを始めようと思っている方は、本記事を参考に少しずつ簡単な所から始めていき、定期的に行っている方にとっては参考の一助となってくれればと思っています。
現場からは以上です。