ソースコードで学ぶWebプログラミング

Webスクレイピングツールの作り方

「Webスクレイピング」とは、Webサイトに掲載の情報やデータを収集することです。 Webサイトを自動的に巡回(クロール)して、利用できるかたちに加工して保存する処理を 行うことになります。

用途としては、機械学習のための統計・ビックデータなどのデータマイニングや 商品情報の収集などがあります。収集したデータの扱いには注意が必要で、 取得したデータが著作物で、第三者に公開した場合には問題になることがあります。

今回は学習用のため、私的利用のみを前提とします。

学べること

・サイトへのアクセス方法 ・タグ構造の解析(正規表現) ・キャッシュログ処理

Webスクレイピングツールに必須の機能とは

プログラムを作成するために、まず要件定義を行います。

それでは「Webスクレイピングツール」に必須の機能をピックアップします。 今回は抽出したデータをcsvファイル化することにします。

1.データ「Webサイトレスポンス」を受け取る 2.取得したデータを解析用に出力する 3.データ「Webサイトレスポンス」を変換する 4.抽出したデータを「csvデータ」として出力する 5.繰り返し処理を行う

機能としては網羅できているようです。さらに詳細を決めていきます。

・データ「Webサイトレスポンス」は、「HTMLデータ」とする ・データ「Webサイトレスポンス」は、キャッシュ化して次回以降はキャッシュから読込 ・データ「Webサイトレスポンス」から、対象となる情報項目を抽出する ・Webサイトへのアクセスは、一定間隔ごとに行う ・「HTMLデータ」は、目視による解析後、正規表現で抽出する ・出力する「csvデータ」の文字コードは、Shift_JISとする ・出力する「csvデータ」は、ダウンロードできる

正規表現とは、文字列内の特定の文字を抽出するための表現方法です。 文字列のパターンを見つけて、一致する表現を記述して抽出します。

/* 正規表現の例 */ $str = 'あいうえお0123456789かきくけこ'; /* 数値を除去 \d が数値を意味し +で1つ以上の繰り返しを意味する */ echo preg_replace('/\d+/', '', $str);//「あいうえおかきくけこ」が表示される /* 数値以外を除去 [^]でそれ以外の集合を意味する [^\d]で数値以外 */ echo preg_replace('/[^\d]+/', '', $str);//「0123456789」が表示される

スクレイピングでは、HTML言語を自動解析するパーサを利用することも多いですが、 正規表現のほうが速く実行でき、微調節も細かくできるので、こちらを採用します。

ここまでの要件をまとめてみます。機能に対して、関連する詳細を当てはめていきます。

1.データ「Webサイトレスポンス」を受け取る ・データ「Webサイトレスポンス」は、「HTMLデータ」とする ・データ「Webサイトレスポンス」は、キャッシュ化して次回以降はキャッシュから読込 2.取得したデータを解析用に出力する ・「HTMLデータ」は、目視による解析後、正規表現で抽出する 3.データ「Webサイトレスポンス」を変換する ・データ「Webサイトレスポンス」から、対象となる情報項目を抽出する 4.抽出したデータを「csvデータ」として出力する ・出力する「csvデータ」の文字コードは、Shift_JISとする ・出力する「csvデータ」は、ダウンロードできる 5.繰り返し処理を行う ・Webサイトへのアクセスは、一定間隔ごとに行う

Webスクレイピングツールの要件をコード化する

それではコーディングに移ります。実際にデータを取得していくほうが分かりやすいので、 今回はハローワークから求人情報を取得する方法で説明を行います。

1.データ「Webサイトレスポンス」を受け取る

PHPでURLへアクセスするには、file_get_contents() や cURL関数が利用できます。 file_get_contents()のほうが簡単ですが、cURLでは細かく設定が行えるので、今回はcURLを利用します。

<?php /* 取得先URL */ define('URL','https://www.hellowork.go.jp/servicef/130020.do?action=initDisp&screenId=130020'); /* URLへアクセス、レスポンスを取得 */ function getUrl($req){ $ch = curl_init(); /* 取得先URL */ curl_setopt($ch, CURLOPT_URL, $req['url']); /* 実行結果を文字列で取得 */ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); /* ヘッダ情報も取得 */ curl_setopt($ch, CURLOPT_HEADER, true); /* ポストデータがある時 */ if (isset($req['post'])) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($req['post'])); } $res = curl_exec($ch); curl_close($ch); return $res; } $req = array( 'url'=>URL, 'post'=>array() ); $data = getUrl($req);

やっている事

・define('URL','') で取得するURLを設定 ・curl_init() でcURL セッションを初期化 ・curl_setopt() でオプションを設定 ・isset($req['post']) postキーのデータを代入した時にPOST送信を行う ・curl_exec() でcURL セッションを実行し、レスポンスを取得 ・curl_close() でcURL セッションを閉じる

取得先のURLの設定は、実際に対象ページのURLを確認しながら決定します。 ハローワークのサイトでは、取得予定のページにPOST遷移を行うようなので、 遷移前の画面データを取得し、POST送信用のフォームを解析する必要があります。 解析方法については後ほど解説します。

現在のコードでは、プログラムが起動する度にURLへアクセスしています。 これでは取得先への負荷の問題もあるので、キャッシュ化して次回以降は キャッシュを読み込むようにします。またURLアクセスに間隔を空けるため、 処理を遅らせるように sleep() 関数を利用します。

/* キャッシュ保管ディレクトリ */ define('DIR_CACHE','cache/'); /* 取得間隔 秒数 */ define('TIME_SPAN',5); /* キャッシュの判別用No */ $cacheNo = 0; /* データを取得 */ function getData($req){ global $cacheNo; /* ドメイン名をファイル名に利用 */ $pre = ''; if (preg_match('{//([a-zA-Z0-9\-\.]+)/}',$req['url'],$m)) { $pre = $m[1]; } /* キャッシュ用ファイル名 */ $f = DIR_CACHE .$pre.'-'.$cacheNo. '.html'; /* キャッシュNoを加算 */ $cacheNo++; /* キャッシュが存在する時 */ if (file_exists($f)) { $data = file_get_contents($f); /* キャッシュが存在しない時 */ }else{ $data = getUrl($req); /* キャッシュとして保存 */ file_put_contents($f, $data); /* リクエスト時に間隔を空ける */ sleep(TIME_SPAN); } return $data; } $req = array( 'url'=>URL, 'post'=>array() ); $data = getUrl($req);

やっている事

・define('DIR_CACHE','') でキャッシュを保管するディレクトリを設定 ・define('TIME_SPAN',5) で取得間隔を設定 ・$cacheNo++ でデータ取得の度にキャッシュNoを変更 ・file_exists() でキャッシュの存在を判定 ・file_get_contents() でキャッシュを読込 ・file_put_contents() で取得データをキャッシュとして保存

今回のシステムは学習用のためデータ取得は少量とし、5秒間隔と十分に時間を取ります。 実務での取得間隔は1秒間程度が多いようです。

注意点として、キャッシュのファイル名に利用しているキャッシュNoは、0から始まる連番となっています。 データ取得順の番号となるため、途中から取得するURLへ変更したりすると整合性が崩れます。

2.取得したデータを解析用に出力する

取得したデータはHTMLなので、そのまま表示するとサイトが表示されてしまいます。 タグをエスケープする方法もありますが、ヘッダーを送信してテキストファイルとして表示させる 方法が簡単です。

/* 解析時用ヘッダ */ header('Content-Type: text/plain; charset="UTF-8"'); echo $data;

これで取得したデータをテキストファイルとして閲覧できます。 スクレイピングでは、タグ構造の解析が必須となります。 タグ構造はサイト毎に異なり、またサイト刷新などにより、時期によっても変わるものとなります。 なので定期的なメンテナンスが必要です。

今回のシステムでは、目視による解析を前提としたデバッグモードを実装します。

/* デバッグモード 1=前生データ 2=前解析 3=前要素解析 4=次生データ 5=フォーム解析 6=フォーム要素解析 7=抽出要素解析 0=しない */ define('DEBUG', 0);

今回の対象サイトでは、取得対象ページへPOST遷移するために「前生データ」「前解析」「前要素解析」を付けますが、 通常は「生データ」「フォーム解析」「フォーム要素解析」「抽出要素解析」のみで良いでしょう。

「生データ」は、取得データをそのまま表示します。「フォーム解析」は、POST遷移などで必要となるPOST送信内容を 解析するためのものです。解析するためには、FORMタグの知識が必要となります。実際にコーディングをしてみます。

/* フォーム要素を取得 */ function getForm($data){ $posts = array(); /* フォームタグ内の要素を取得 */ if (preg_match_all('{<form.+?</form>}s',$data,$a)) { /* フォームの送信先を取得 */ if (preg_match('{<form[^>]+action="([^"]+)"[^>]*>}s',$data,$u)) { $posts['url'] = $u[1]; } foreach($a[0] as $r){ /* フォーム要素を取得 */ if (preg_match_all('{<[^>]+name=[^>]+>}s',$r,$e)) { $posts[] = $e; } } } return $posts; }

やっている事

・送信予定となる項目確認用に変数 $posts を用意 ・preg_match_all() でページ内のformタグと内要素を取得 ・preg_match() で送信先を取得 ・preg_match_all() でフォーム要素を取得

フォーム内のinputやselect、textarea、hiddenではname属性が必須なので、name属性で取得します。

他に押さえるべき要点としては、HTML画面内には複数のフォームが存在し得るので、 preg_match_all() で複数取得している点があります。

現在のコードでは、タグの状態がそのまま表示されます。不要なタグなどがあれば、正規表現を調節します。 大分、解析しやすい状態になっていますが、実際の送信内容の連想配列でも確認できるようにします。

/* フォーム要素の送信パターンを取得 */ function getFormElement($data){ $posts = array(); /* フォームタグ内の要素を取得 */ if (preg_match_all('{<form.+?</form>}s',$data,$a)) { /* フォームの送信先を取得 */ if (preg_match('{<form[^>]+action="([^"]+)"[^>]*>}s',$data,$u)) { $posts['url'] = $u[1]; } foreach($a[0] as $f=>$r){ /* フォーム要素を取得 */ if (preg_match_all('{<[^>]+name="([^"]+)"[^>]+>}s',$r,$e)) { foreach($e[0] as $i=>$r){ $name = $e[1][$i]; /* type、value がある時のみ取得 */ if (preg_match('{type="([^"]+)"}',$r,$t) && preg_match('{value="([^"]*)"}',$r,$v)) { $posts[$f][$t[1]][$name][] = $v[1]; } } } } } return $posts; }

やっている事

・preg_match() でフォーム毎にtypeとnameで分けてvalueを代入

今回の対象サイトではselect、textareaが無かったのでコーディングしていませんが、 必要に応じて追加します。

フォーム要素の送信パターンとコメントを記述していますが、submitボタン、radioボタン、checkboxボタンなどは、 フォーム内に存在しても必ずしも送信される要素では無いためです。 送信すべき内容はサイト毎に異なるため、解析結果を見ながら、実際に送信する要素を決める必要があります。

3.データ「Webサイトレスポンス」を変換する

それでは、取得したフォーム要素から実際に送信を行う要素を設定します。 今回の対象サイトでの実装を見てみます。まずは取得したいページへ遷移するフォーム要素です。 東京都の求人情報ページを取得してみます。

/* 初期画面のフォーム要素から送信データを設定 */ function setFormFirst($data){ $posts = array(); /* フォームタグ内の要素を取得 */ if (preg_match_all('{<form.+?&;t/form>}s',$data,$a)) { /* フォーム要素を取得 */ if (preg_match_all('{<[^>]+name="([^"]+)"[^>]+>}s',$a[0][0],$e)) { foreach($e[0] as $i=>$r){ $name = $e[1][$i]; if (preg_match('{type="([^"]+)"}',$r,$t) && preg_match('{value="([^"]*)"}',$r,$v)) { /* 送信データを設定 */ switch($t[1]){ case 'submit': if ($name=='searchTodofuken13') $posts[$name] = $v[1]; break; case 'text': break; case 'radio': if ($name=='kyujinShurui' && $v[1]==1) $posts[$name] = $v[1]; break; case 'check': break; case 'hidden': $posts[$name] = $v[1]; break; } } } } } return $posts; }

やっている事

・$name=='searchTodofuken13' で東京都をクリックした状態に ・$name=='kyujinShurui' && $v[1]==1 で一般(フルタイム)を選択した状態に ・hidden 要素はすべて送信

続いて取得したいページのフォーム要素を設定します。

/* 次画面のフォーム要素から送信データを設定 */ function setFormNext($data){ $posts = array(); /* フォームタグ内の要素を取得 */ if (preg_match_all('{<form.+?</form>}s',$data,$a)) { /* フォーム要素を取得 */ if (preg_match_all('{<[^>]+name="([^"]+)"[^>]+>}s',$a[0][0],$e)) { foreach($e[0] as $i=>$r){ $name = $e[1][$i]; if (preg_match('{type="([^"]+)"}',$r,$t) && preg_match('{value="([^"]*)"}',$r,$v)) { /* 送信データを設定 */ switch($t[1]){ case 'submit': if ($name=='fwListNaviBtnNext') $posts[$name] = $v[1]; break; case 'text': break; case 'radio': break; case 'check': break; case 'hidden': $posts[$name] = $v[1]; break; } } } } } return $posts; }

やっている事

・$name=='fwListNaviBtnNext' で次へ>>をクリックした状態に ・hidden 要素はすべて送信

これでプログラムでサイト内を巡回することが出来るようになりました。

続いて、取得したページから取得したい項目を抽出していきます。

/* 対象となる項目を抽出 */ function getItems($data){ $items = array(); /* 抽出対象を限定 */ if (preg_match('{<div class="d-sole">.+<div class="number-link-bottom" style="width: 810px;">}s',$data,$r)) { $data = $r[0]; } /* 抽出対象の1レコード分毎を指定 */ if (preg_match_all('{<tr>.+?</tr>}s',$data,$a)) { foreach($a[0] as $i=>$r){ /* 抽出対象の1カラム分を指定 */ if (preg_match_all('{<td[^>]+>(.+?)</td>}s',$r,$m)) { /* 1カラム目を除去 */ $d = array_slice($m[1], 1); /* 不要な文字を除去 */ $d = preg_replace('{[\t\r\n]}','',$d); /* リンクからURLを抽出 */ $d = array_map(function($s){ if (preg_match('{href="([^"]+)"}',$s,$u)) { $s = $u[1]; } return $s; },$d); /* タグを除去 */ $d = array_map('strip_tags',$d); $items[$i] = $d; } } } return $items; }

やっている事

・preg_match() で取得対象のHTMLデータの範囲を限定 ・preg_match_all() で1レコード分の範囲を限定 ・preg_match_all() で1カラム分を指定 ・array_slice() で1カラム目を除去 ・preg_replace() で全カラムから不要な文字を除去 ・array_map() で全カラム内のリンクをURLに置き換え ・array_map() で全カラム内のタグ要素を除去

実際の情報の抽出作業では、タグ構造の解析をしながら正規表現を設定し、取得できているかの 確認になります。今回のシステムでは、デバッグモード 7=抽出要素解析 で取得状況を確認できます。

4.抽出したデータを「csvデータ」として出力する

csvデータへの変換、保存は、繰り返し処理の中でレスポンス取得ごとに実行します。 そのため、初回時は出力データを初期化し、以降は追記保存します。

/* 保存ファイル名 */ define('CSV_FILE','items.csv'); /* 出力データを初期化 */ file_put_contents(CSV_FILE,''); /* 項目を取得 */ $items = getItems($data); /* csvデータとして出力保存 */ $fp = fopen(CSV_FILE,'a'); foreach($items as $c){ /* Shift_JISに変換 */ $c = array_map(function($s){ return mb_convert_encoding($s, 'SJIS-win', 'UTF-8'); },$c); fputcsv($fp,$c); } fclose($fp); /* ダウンロードをさせる */ if (DEBUG==0) { /* csvダウンロード */ header('Content-Disposition: attachment; filename="pickup'.date('YmdHi').'.csv"'); header('Content-Type: application/octet-stream'); header('Content-Length: '.filesize(CSV_FILE)); readfile(CSV_FILE); } exit;

やっている事

・file_put_contents('','') でデータを初期化(空データに) ・getItems() で抽出項目を取得 ・fopen('','a') でファイルに追記保存 ・array_map() で全取得項目の文字コードをUTF-8からShift_JISに変換 ・fputcsv() でcsvデータとしてファイル書込 ・filesize() でファイルサイズを取得 ・readfile() でファイルを出力

csvデータは処理完了時にダウンロードさせるかたちにします。記述位置に注意して下さい。

5.繰り返し処理を行う

今回のシステムでは単純にループ処理します。また学習用のため10ページ分としていますが、 サイトの画面上に全件ページ数が表示されている場合は、取得して利用するなどが考えられます。 既に解説済みのコードについては省略しています。

/* 10ページ分を取得 */ for($i=0;$i<10;$i++){ /* 1ページ目 */ if ($i==0) { /* 1ページ目処理 */ } else { /* 次ページ取得 */ } switch(DEBUG){ /* 次生データ */ case '4': echo $data; exit; /* フォーム解析 */ case '5': print_r( getForm($data) ); exit; /* フォーム要素解析 */ case '6': print_r( getFormElement($data) ); exit; /* 抽出要素解析 */ case '7': print_r( getItems($data) ); exit; case '0': /* 抽出項目の保存処理 */ break; } }

やっている事

・for() で取得回数をコントロール ・$i==0 で1回目の取得処理と他処理を分岐 ・switch(){} でデバッグモードを処理分け ・print_r() で解析状態を表示

以上で完成となります。

プログラム完成後のカスタマイズ

ソースファイルはこちらより入手(購入)可能です。

今回は学習用として作成しているため、必要最小限の機能となっていますが、 下記のようなカスタマイズで、高機能化を目指せます。

カスタマイズ例

・ページ数を取得して、全ページを自動取得 ・リンク先の詳細ページなどを取得 ・画像ファイルを取得し、ファイル名をレコードと関連付け ・実行時間対策として、ajax起動 ・BASIC認証に対応 ・クッキーに対応してログイン後画面から取得できるように ・キャッシュファイル名を処理ページ単位で判別できるように ・キャッシュデータに有効期限を設定する