コード実行結果のサンプル
HTTP リクエスト ⇒ レスポンスの様子がこんな風に分かります。
[0] => Array
(
[url] => http://qiita.com/fallout/
[request] => GET /fallout/ HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35
[response] => Array
(
[0] => HTTP/1.1 301 Moved Permanently
[date] => Fri, 21 Jun 2019 06:06:53 GMT
[content-type] => text/html
[content-length] => 178
[connection] => close
[server] => nginx
[location] => http://qiita.com/fallout
)
[stream] => Array
(
[timed_out] =>
[blocked] => 1
[eof] =>
[stream_type] => tcp_socket/ssl
[mode] => r+
[unread_bytes] => 178
[seekable] =>
)
[cookie] => Array
(
)
)
[1] => Array
(
[url] => http://qiita.com/fallout
[request] => GET /fallout HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35
[response] => Array
(
[0] => HTTP/1.1 301 Moved Permanently
[date] => Fri, 21 Jun 2019 06:06:53 GMT
[content-type] => text/html
[transfer-encoding] => chunked
[connection] => close
[server] => nginx
[location] => https://qiita.com/fallout
[x-request-id] => *****
)
[stream] => Array
(
[timed_out] =>
[blocked] => 1
[eof] =>
[stream_type] => tcp_socket/ssl
[mode] => r+
[unread_bytes] => 5
[seekable] =>
)
[cookie] => Array
(
)
)
[2] => Array
(
[url] => https://qiita.com/fallout
[request] => GET /fallout HTTP/1.1
Host: qiita.com
Connection: close
Accept-Encoding: gzip
User-Agent: Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35
[response] => Array
(
[0] => HTTP/1.1 200 OK
[date] => Fri, 21 Jun 2019 06:06:54 GMT
[content-type] => text/html; charset=utf-8
[transfer-encoding] => chunked
[connection] => close
[server] => nginx
[x-frame-options] => SAMEORIGIN
[x-xss-protection] => 1; mode=block
[x-content-type-options] => nosniff
[x-download-options] => noopen
[x-permitted-cross-domain-policies] => none
[referrer-policy] => strict-origin-when-cross-origin
[etag] => *****
[cache-control] => max-age=0, private, must-revalidate
[set-cookie] => _qiita_login_session=*****; domain=.qiita.com; path=/; expires=Sun, 21 Jun 2020 06:06:54 -0000; HttpOnly
[x-runtime] => 0.172956
[strict-transport-security] => max-age=2592000
[x-request-id] => *****
[content-encoding] => gzip
)
[stream] => Array
(
[crypto] => Array
(
[protocol] => TLSv1.2
[cipher_name] => ECDHE-RSA-AES128-GCM-SHA256
[cipher_bits] => 128
[cipher_version] => TLSv1.2
)
[timed_out] =>
[blocked] => 1
[eof] =>
[stream_type] => tcp_socket/ssl
[mode] => r+
[unread_bytes] => 7134
[seekable] =>
)
[cookie] => Array
(
[_qiita_login_session] => *****
)
)
コード
コメントを読みながら、HTTP のリクエスト ⇒ レスポンスという流れを、何となく感じ取ってもらえたらと思います。
- 学習用のコードです。コピペで使う事は意図していません。
- PHP5.2.X あたりのかなり古い環境でも動くように書いたつもりですが、実際の動作は PHP7.3.6 で簡単に確認しただけです。
- 引数のチェックやエラー処理は省略してます。
- GET と POST メソッドに対応しています。
function fetchUrl($url, $postAry = array(), $cookieAry = array())
{
//-- タイムアウト(秒) ※【補足1】
$timeout = 10;
//-- リダイレクト回数制限(回)
$maxRedirects = 5;
//-- リダイレクト回数
static $cntRedirect = 0;
//-- レスポンスヘッダなどの履歴
static $historyAry = array();
//-- URLの構成要素を配列にする
$urlAry = parse_url($url);
if (!$urlAry || !isset($urlAry['scheme'])) {
return false;
}
//-- URLの履歴
$historyAry[$cntRedirect]['url'] = $url;
//-- 接続するソケットのアドレス
$remote = '';
//-- https ⇒ tls接続(デフォルトのportは443番)
//-- http ⇒ tcp接続(デフォルトのportは80番)
if ($urlAry['scheme'] === 'https') {
$remote = "tls://{$urlAry['host']}:";
$remote .= (isset($urlAry['port'])) ? $urlAry['port'] : '443';
} elseif ($urlAry['scheme'] === 'http') {
$remote = "tcp://{$urlAry['host']}:";
$remote .= (isset($urlAry['port'])) ? $urlAry['port'] : '80';
} else {
return false;
}
//-- リクエストヘッダ
$reqAry = array();
//-- Hostを指定する
$reqAry['Host'] = $urlAry['host'];
//-- keep-aliveは未対応
$reqAry['Connection'] = 'close';
//-- gzip圧縮転送へ対応
if (function_exists('gzinflate')) {
$reqAry['Accept-Encoding'] = 'gzip';
}
//-- UserAgentを設定する
$reqAry['User-Agent'] = 'Qiita/fallout Sample; +https://qiita.com/fallout/items/df7262a079d27f1b1d35';
//-- リファラなども同様に処理できます
// $reqAry['Referer'] = 'http://...';
//-- Basic認証 ※【補足2】
$authUser = (isset($urlAry['user'])) ? $urlAry['user'] : '';
$authPass = (isset($urlAry['pass'])) ? $urlAry['pass'] : '';
if ($authUser !== '' || $authPass !== '') {
$reqAry['Authorization'] = 'Basic ' . base64_encode("{$authUser}:{$authPass}");
}
//-- クッキー
if ($cookieAry) {
$reqAry['Cookie'] = http_build_query($cookieAry, '', '; ');
}
//-- リクエストメソッド
$method = 'GET';
$content = null;
//-- POSTメソッド
if ($postAry) {
$method = 'POST';
$content = http_build_query($postAry, '', '&');
$reqAry['Content-Type'] = 'application/x-www-form-urlencoded';
$reqAry['Content-Length'] = strlen($content);
}
//-- リクエスト対象パス
$path = (isset($urlAry['path'])) ? $urlAry['path'] : '/';
//-- クエリストリングを追加
if (isset($urlAry['query'])) {
$path .= "?{$urlAry['query']}";
}
//-- HTTP/1.1でリクエスト
$request = "{$method} {$path} HTTP/1.1\r\n";
//-- リクエストラインの生成
foreach ($reqAry as $name => $value) {
$request .= "{$name}: {$value}\r\n";
}
//-- リクエストラインの最後は改行のみ
$request .= "\r\n";
//-- POSTメソッド用リクエストボディを追加
if ($method === 'POST') { $request .= $content; }
//-- リクエストの履歴
$historyAry[$cntRedirect]['request'] = $request;
//-- 接続開始
if (!$sock = stream_socket_client($remote, $errNo, $errStr, $timeout)) {
return false;
}
//-- 簡易的なタイムアウト処理
if (!stream_set_timeout($sock, $timeout)) {
return false;
}
//-- リクエストを送信
if (fwrite($sock, $request) === false) {
return false;
}
//-- レスポンスヘッダを取得する
$resAry = array();
//-- 改行のみで終わる行までがレスポンスヘッダ
while ($line = trim(fgets($sock))) {
//-- ヘッダフィールドを配列にする
if (strpos($line, ':') !== false) {
list($name, $value) = explode(':', $line, 2);
//-- 小文字で統一
$name = strtolower(rtrim($name));
$value = ltrim($value);
if (isset($resAry[$name])) {
//-- cookieなど複数回登場するフィールドの処理
if (!is_array($resAry[$name])) {
$resAry[$name] = (array) $resAry[$name];
}
$resAry[$name][] = $value;
} else {
$resAry[$name] = $value;
}
} else {
$resAry[] = $line;
}
}
//-- レスポンスヘッダの履歴
$historyAry[$cntRedirect]['response'] = $resAry;
//-- タイムアウト
$resMetaAry = stream_get_meta_data($sock);
if (!empty($resMetaAry['timed_out'])) {
return false;
}
//-- ストリーム情報の履歴
$historyAry[$cntRedirect]['stream'] = $resMetaAry;
//-- クッキーの処理 ※【補足3】
$resCookieAry = array();
if (!empty($resAry['set-cookie'])) {
foreach ((array) $resAry['set-cookie'] as $value) {
if (preg_match('/\A([^\=]+)\=([^;]+)/', $value, $matchAry)) {
$resCookieAry[urldecode($matchAry[1])] = urldecode($matchAry[2]);
}
}
$cookieAry = array_merge($cookieAry, $resCookieAry);
}
//-- クッキーの履歴
$historyAry[$cntRedirect]['cookie'] = $cookieAry;
//-- リダイレクト ※【補足4】
if (!empty($resAry['location']) && ++$cntRedirect <= $maxRedirects) {
//-- 接続を閉じる
fclose($sock);
//-- リダイレクト先
$location = (is_array($resAry['location'])) ? end($resAry['location'])
: $resAry['location'];
//-- 再帰処理(リダイレクト先はGETメソッド)
return fetchUrl($location, array(), $cookieAry);
}
//-- チャンク形式のデコード【補足5】
$isChunked = isset($resAry['transfer-encoding']) && $resAry['transfer-encoding'] === 'chunked';
$isDechunk = false;
if ($isChunked) {
//-- dechunkフィルタの使用
if (in_array('dechunk', stream_get_filters())) {
stream_filter_append($sock, 'dechunk', STREAM_FILTER_READ);
$isDechunk = true;
}
}
//-- レスポンスボディ
$resBody = stream_get_contents($sock);
//-- 接続を閉じる
fclose($sock);
//-- dechunkフィルタが使えない環境では自力でデコード
if ($isChunked && !$isDechunk) {
$decoded = '';
$tmp = $resBody;
//-- 「2」は"\r\n"の長さの事です
do {
$tmp = ltrim($tmp);
$pos = strpos($tmp, "\r\n");
$len = hexdec(substr($tmp, 0, $pos));
$decoded .= substr($tmp, $pos + 2, $len);
$tmp = substr($tmp, $pos + 2 + $len);
$check = trim($tmp);
} while (!empty($check));
$resBody = $decoded;
}
//-- gzip圧縮データのデコード
if (isset($resAry['content-encoding']) && $resAry['content-encoding'] === 'gzip') {
$resBody = (function_exists('gzdecode')) ? gzdecode($resBody)
: gzinflate(substr($resBody, 10, -8));
}
//-- 取得結果を返す
return array(
$historyAry, // レスポンスヘッダなどの履歴
$resBody, // 最終レスポンスボディ
);
}
使い方
//-- ユーザー名の後に「/」を入れると2回リダイレクトする様子が観察できます
list($historyAry, $body) = fetchUrl('http://qiita.com/fallout/');
print_r($historyAry);
//-- POSTメソッドにも対応しています
$postAry = [
'user' => '名前',
'pass' => 'ぱすわぁど',
];
fetchUrl('http://example.com/', $postAry);
//-- 一応クッキーは処理しますが致命的な問題があります ※【補足3】
$cookieAry = [
'aaa' => 'あああ',
'bbb' => 'いいい',
];
fetchUrl('http://qiita.com/fallout/', array(), $cookieAry);
//-- PHP5.4以降であればこういう使い方もできます
$body = fetchUrl('http://qiita.com/fallout/')[1];
//-- Basic認証やport番号指定にも一応対応しています
fetchUrl('http://user:pass@example.com:80/');
【補足1】タイムアウト
10 秒に制限していますが、本来こういうハードコーディングは避けるべきです。
また、簡易的な処理しかしていません。
それでも file_get_contents()
よりはマシだと思いますが、きちんと処理するのであれば、microtime()
などを使ってチェックするようにします。
$startTime = microtime(true);
while (fgets($sock) !== false) {
if (microtime(true) - $startTime >= $timeout) {
// タイムアウト処理
}
【補足2】Basic認証
ユーザー名とパスワードとを「:」で繋げて、Base64 形式でエンコードしただけだという事が分かると思います。
.htaccess
で簡単に設定できるため、未だに利用者が多いですが、ユーザー名とパスワードは、暗号化もハッシュ化もされていないという事になります。
【補足3】クッキーの処理
このコードには、致命的な問題があります。
例えば 1.qiita.com
⇒ 2.example.com
にリダイレクトした際に、1 で発行されたクッキーが 2 に漏れてしまいますし、クッキーに対して付与される様々な属性も無視しています。
あくまでも流れや雰囲気をつかむためのコードですので、決して本番環境などではマネしないでください。
【補足4】リダイレクト
5 回に制限していますが、本来こういうハードコーディングは避けるべきです。
また、location
が「相対パス」で返ってきた際の処理は、省略していますので、下記を参考にしてみてください。
【補足5】チャンク形式のデコード
チャンク形式の仕様は RFC 7230 にありますが、要するに…
[Aのサイズ(16進数)]
[データA]
[Bのサイズ(16進数)]
[データB]
0
のような形式で、データが返ってくるという事です。
PHP には、これをストリーム処理できる dechunk
というフィルタがありますが、古い環境では使えなかった記憶があるので、念のため自力でデコードする処理も書いています。
処理速度
処理速度は、file_get_contents() < 私のコード < cURL になると思います。
※あくまでも理論上の話で、検証はしていません。
実用レベルでは、並列処理なども可能な cURL が断然オススメです。
※Guzzle などは、内部的には cURL を利用しています。
【オマケ】キャッシュを作る例
一度取得した結果をキャッシュする際は、serialize()
/ unserialize()
を使うと簡単に処理が書けます。
スクレイピングでは、一度取得した結果をキャッシュして、データ取得先に負荷をかけない工夫が必要ですので、参考にしてみてください。
//-- 関数に渡すパラメータ
$paramAry = [
'url' => 'http://example.com/',
'post' => ['aaa' => 'あああ'],
'cookie' => ['test' => 'テスト'],
];
//-- パラメータをシリアライズした結果のハッシュ値をファイル名にする
$cacheFilename = md5(serialize($paramAry)) . '.cache';
//-- データを取得
$dataAry = fetchUrl($paramAry['url'], $paramAry['post'], $paramAry['cookie']);
//-- シリアライズしてキャッシュとして保存
file_put_contents("./{$cacheFilename}", serialize($dataAry), LOCK_EX);
//-- キャッシュを利用する際はアンシリアライズする
$dataAry = unserialize(file_get_contents("./{$cacheFilename}")));
迷惑をかけている PHPer
配慮の足りないスクレイピングプログラム作者へ物申すでは明言を避けましたが、残念なスクレイピングで迷惑をかけている人の多くが、PHP を使っています。
ログに GET ***** HTTP/1.0
と、HTTP/1.0 のリクエストがやたら元気よく踊っていれば1、かなりの確率で残念 PHPer の仕業です。
加えて、IP アドレスがレンタルサーバーのものなら、確実にそうだと言い切って良いです。
file_get_contents()
その理由はおそらく、file_get_contents()
のような、とても手軽に使える標準関数があるからだと思います。
例えば、http://qiita.com/fallout
の内容を取得したいと思えば…
$html = file_get_contents('http://qiita.com/fallout');
このたった 1 行で、http://
⇒ https://
と 301 リダイレクトした結果の中身を、取得できてしまいます。お手軽ですね。
レスポンスヘッダも見てみる
レスポンスヘッダも見たいと思えば…
$html = file_get_contents('http://qiita.com/fallout');
print_r($http_response_header);
// Array
// (
// [0] => HTTP/1.1 301 Moved Permanently
// [5] => Location: https://qiita.com/fallout
// )
リダイレクト前の結果のみですが、この 2 行で見れてしまいます。2
もし、ヘッダを取得するだけであれば、get_headers()
なら、同じ結果を 1 行で取得できます。お手軽ですね。
より複雑なことも
第 3 引数へ、ストリームコンテキストを渡せば、もっと複雑な事…
$result = file_get_contents(
'http://example.com/',
false,
stream_context_create(['~ 略 ~']),
0, 30
);
…例えば、example.com
へ、○○のデータを HTTP/1.1 で POST し、その結果の先頭から 30byte を取得する…といった事までできます。3
あえて車輪の再発明
そんな便利な標準関数や Guzzle などを、あえて使わないコードを見てもらえば、HTTP の事がほんの少し分かって、スクレイピングに対する理解も深まるかも?と思ったのが、この記事を書いた理由です。
色んな事を思い出しながら書いたのと、ロクに動作検証をしていないので、おかしなところがありましたらご指摘ください。
※本筋からそれる処理は省略してます。
車輪の再発明にあえてチャレンジすることも、それはそれで勉強になると思います。
- ファイルをアップロードする時はどう書くのか?
- 関数ではなくクラス化してみよう
など、色々とチャレンジしてみれば、より理解が深まるのではないでしょうか。
うわぁ…面倒くさい…「やっぱり file_get_contents() でいいや!」というオチは、ご勘弁を(笑) 他人に迷惑をかける PHPer が 1 人でも減ってくれることを、心から願っております。
-
令和の時代なら、HTTP/1.0 を拒否すれば済む話かもしれませんが、HTTP/1.0 のリクエストを眺めるのは、それはそれで楽しかったりします。 ↩
-
$http_response_header
という変数が、何の前触れもなく現れるのはイケてませんが、PHP 4 時代の名残だとでも思えば…。 ↩ -
ちょっとやりすぎ感はあります。関数やメソッドを自作する際は、役割をできるだけ「シンプル」にするよう意識すると、見通しも使い勝手も良い「美しい」コードになると思います。 ↩