PHPでリンク切れを調べる

(1/1)
PHP では、ローカルディスクにあるファイルとインターネット上のコンテンツを同様に扱うことができる。
この特徴を利用して、ホームページ内に書かれているリンク先が存在しているかどうか、存在する場合はコンテンツのサイズがゼロでないかどうかをチェックするプログラムを作ってみることにする。
あわせて、W3C が提供する Web サービスを利用し、HTML が文法的に正しいかをチェックする。

(2021 年 6 月 5 日)get_context()追加,USER_AGENT 変更。
(2020 年 12 月 19 日)HTTP ヘッダ偽装を追加。
(2020 年 12 月 5 日)PHP 8 対応。

目次

サンプル・プログラムの実行例

PHPでリンク切れを調べる

サンプル・プログラム

解説:404エラーのチェック

0299: /**
0300:  * リンク先の有無を表示する
0301:  * @param   string $url      チェックするURL
0302:  * @param   int    $flag_a   <a>タグのハイパーリンク先をチェックする
0303:  * @param   int    $flag_img <img>タグの画像ソースをチェックする
0304:  * @param   array  $items    チェック結果を格納する配列
0305:  * @param   string $exclude  チェック除外するURLパターン(正規表現;省略可)
0306: */
0307: function check404($url$flag_a$flag_img, &$items$exclude='') {
0308:     $context = get_context();
0309:     $fp = @fopen($url, 'r', FALSE$context);
0310:     if ($fp == FALSE)   return $url . ' は存在しない.';
0311: 
0312:     $ln = 1;
0313:     while (! feof($fp)) {
0314:         $s = fgets($fp);
0315:         if ($flag_a) {
0316:             $pattern = "/<a.+?href\=\"([^\"]+)\"/i";
0317:             check_link($pattern, 1, $s$ln$url$items$exclude);
0318:         }
0319:         if ($flag_img) {
0320:             $pattern = "/<img.+?src\=\"([^\"]+)\"/i";
0321:             check_link($pattern, 1, $s$ln$url$items$exclude);
0322:         }
0323:         $ln++;
0324:     }
0325:     fclose($fp);
0326: 
0327:     return '';
0328: }

このプログラムの肝となるのはユーザー定義関数 check404 である。

調査するコンテンツ(ホームページ)の URL を引数にし、その中に出現する <a> タグおよび <img> タグのリンク先が  fopen  できるかどうかを調べる。404 エラー(コンテンツが存在しない)なら、 fopen  は失敗する。
まず、調査するコンテンツの存在をチェックする。無ければエラーメッセージを返す。
次に、調査するコンテンツを 1行ずつ読み込み、<a> タグや <img> タグにパターンマッチする行があれば解析する。
<a> タグおよび <img> タグにパターンマッチするかどうかを調べるために正規表現を用いている。
実際のパターンマッチと存在性チェックは、ユーザー定義関数 check_link が行う。

なお、ブラウザ以外からのアクセスを拒否するページもあるので、 fopen  関数実行時に、あたかもブラウザからアクセスしたようにみせかけるために、HTTP ヘッダを送信する。そのためのデータを  fopen  関数に渡すのが、 stream_context_create  関数である。HTTP ヘッダは配列 $header に用意しておく。
また、SSL サーバ証明書の有効期限切れなども無視するよう、検証要求をスキップするために、配列 $ssl に値をセットしてある。

解説:パターンに一致するものがあるかどうか

0247: /**
0248:  * パターンに一致するものがあるかどうかチェックする
0249:  * @param   string $pattern パターン(正規表現)
0250:  * @param   int    $n       パターンに一致する場所(正規表現内の順序)
0251:  * @param   string $str     チェック対象文字列
0252:  * @param   int    $ln      解析中の行番号
0253:  * @param   string $url     解析中のURL
0254:  * @param   array  $items   チェック結果を格納する配列
0255:  * @param   string $exclude チェック除外するURLパターン(正規表現;省略可)
0256: */
0257: function check_link($pattern$l$str$ln$url, &$items$exclude='') {
0258:     $n = preg_match_all($pattern$str$arr);
0259: 
0260:     for ($i = 0; $i < $n$i++) {
0261:         $target = reg_urls($arr[$l][$i]$url);
0262:         //除外パターンのチェック
0263:         if ($exclude !='') {
0264:             if (preg_match('/' . $exclude . '/ui', $target) > 0)    continue;
0265:         }
0266:         //ファイル・オープン
0267:         $cfp = @fopen($target, 'r');
0268:         $arr2['ln']  = $ln;
0269:         $arr2['url'] = $target;
0270:         $arr2['res'] = 'あり';
0271: 
0272:         if (function_exists('curl_exec')) {
0273:             list($code$size) = getHttpResponseCodeSize($target);
0274:             if (($code != NULL) && ($code != 200)) {
0275:                 $arr2['res'] = 'なし';
0276:                 $arr2['size'] = (-1);
0277:             //サイズ>0 または パス指定
0278:             } else if (($size > 0) || (preg_match('/\/$/ui', $target) > 0)) {
0279:                 $arr2['res'] = 'あり';
0280:                 $arr2['size'] = $size;
0281:             } else {
0282:                 $arr2['res'] = 'ゼロ';
0283:                 $arr2['size'] = 0;
0284:             }
0285:         } else if ($cfp == FALSE) {
0286:             $arr2['res'] = 'なし';
0287:             $arr2['size'] = (-1);
0288:         } else {
0289:             $size = contents_size($target);
0290:             if ($size <= 0) {
0291:                 $arr2['res'] = 'ゼロ';
0292:                 $arr2['size'] = 0;
0293:             }
0294:         }
0295:         $items[] = $arr2;
0296:     }
0297: }

ユーザー定義関数 check_link では、マッチした部分文字列(実際にはアドレスが入る)を取り出す。
このとき、相対アドレス指定(http:で始まらない)があると  fopen  関数が適用できないので、ユーザー定義関数 reg_urls により URL の正規化を行う。
正規化した URL が、除外パターン $exclude にマッチしていたら、チェックをスキップする。

存在性チェックは  fopen  関数を利用する。ネット上のコンテンツに対しては  file_exists  関数は利用できないためだ。
なお、404 エラーの場合、fopen 自身が失敗するので、@ を使ってエラー発生を抑えている。
ファイルが存在しなければ、fopen の戻り値は FALSE となる。

解説:HTTPレスポンスコード、サイズの取得

0206: /**
0207:  * HTTPレスポンスコード、サイズを取得
0208:  * @param   string $url 解析中のURL
0209:  * @return  array(HTTPレスポンスコード:200 正常/NULL 取得失敗, サイズ)
0210: */
0211: function getHttpResponseCodeSize($url) {
0212:     $header = NULL;
0213:     $options = array(
0214:         CURLOPT_RETURNTRANSFER => TRUE,
0215:         CURLOPT_HEADER         => TRUE,
0216:         CURLOPT_FOLLOWLOCATION => TRUE,
0217:         CURLOPT_ENCODING       => '',
0218:         CURLOPT_USERAGENT      => USER_AGENT,
0219:         CURLOPT_SSL_VERIFYPEER => FALSE,
0220:         CURLOPT_SSL_VERIFYHOST => FALSE,
0221:         CURLOPT_AUTOREFERER    => TRUE,
0222:         CURLOPT_CONNECTTIMEOUT => 120,
0223:         CURLOPT_TIMEOUT        => 120,
0224:         CURLOPT_MAXREDIRS      => 10
0225:     );
0226:     $ch = curl_init($url);
0227:     curl_setopt_array($ch$options);
0228:     curl_exec($ch);
0229: 
0230:     //cURLエラーチェック
0231:     if (curl_errno($ch)) {
0232:         curl_close($ch);
0233:         return array(NULLNULL);
0234:     }
0235:     $header = curl_getinfo($ch);
0236:     curl_close($ch);
0237: 
0238:     //コンテンツ・サイズ
0239:     $size = $header['download_content_length'];
0240:     if (($header['http_code'] == 200) && ($size < 0)) {
0241:         $size = contents_size($url);
0242:     }
0243: 
0244:     return array($header['http_code'], $size);
0245: }

ユーザー定義関数 check_link でリンク先が存在していた場合でも、404 エラーなどでジャンプしている場合を想定し、ユーザー定義関数 getHttpResponseCode を用意した。この関数は、HTTP レスポンスコードを返す。
同様の機能を持つ組み込み関数  http_response_code  が用意されているが、PHP5 以上でないと利用できない。

そこで、HTTP レスポンスコードの取得は、cURL関数を利用することにした。
組み込み関数  curl_getinfo  を使って、HTTP CODE を受け取る。これが 200 であれば、正常受信できている。

次に、download_content_length を使って、HTTP通信で送られてくる Content-Length がゼロでないかどうかをチェックする。
ただし、HTTP サーバによっては Content-Length を送信しないことがあるので、その場合、自力でコンテンツ・サイズを求めるユーザー関数 contents_size を呼び出す。

解説:コンテンツ・サイズの取得

0188: /**
0189:  * http上のファイルサイズ取得
0190:  * @param   string $url 対象コンテンツ
0191:  * @param   long   ファイルサイズ
0192: */
0193: function contents_size($url) {
0194:     $data = '';
0195:     $context = get_context();
0196:     $fp = @fopen($url, 'r', FALSE$context);
0197:     if (! $fp)       return (-1);
0198:     while (! feof($fp)) {
0199:         $data .= fread($fp, 1024);
0200:     }
0201:     fclose($fp);
0202: 
0203:     return strlen($data);
0204: }

組み込み関数  filesize  ではネット上のファイルのサイズを取得することはできない。
そこで、コンテンツを読み込んでサイズを数えるユーザー関数 contents_size を用意した。

解説:URLの正規化

なお、ハイパーリンクに記述されている URL は相対パス(例:"./index.htm")のこともあるので、ステータスを調べるときに支障が出る。そこで、すべて絶対パスに正規化する必要がある。

URL の正規化処理は、ユーザー関数 reg_urls で行う。

0114: /**
0115:  * URLを正規化する(相対指定を絶対指定に変換する)
0116:  * @param   string $url  正規化するURL
0117:  * @param   string $sour 読み込んだコンテンツのURL
0118:  * @return  string 正規化したURL / FALSE(正規化に失敗)
0119: */
0120: function reg_urls($url$sour) {
0121:     //httpで始まればスキップ
0122:     if (preg_match('/^http/', $url) != FALSE)   return $url;
0123: 
0124:     $sour = rtrim($sour, '/');      //末尾のスラッシュは除く
0125:     //相対指定の場合
0126:     if (preg_match('/\:\/\//', $url) == FALSE) {
0127:         $regs = parse_url($sour);
0128:         $dirname = isset($regs['path']) ? dirname($regs['path']) : '';
0129:         if (preg_match('/^\//', $url) > 0) {
0130:             $url = ltrim($url, '/');        //冒頭のスラッシュは除く
0131:             $dirname = '';
0132:         }
0133:         $url = $regs['scheme'] . '://' . $regs['host'] . $dirname . '/' . $url;
0134:     }
0135:     //相対指定を絶対指定に変換する
0136:     $regs = parse_url($url);
0137:     $aa = preg_split("/[\/\\\]/", $regs['path']);
0138:     $an = count($aa);
0139:     $bb = array();
0140:     $bn = 0;
0141:     for ($i = 1; $i < $an$i++) {
0142:         switch ($aa[$i]) {
0143:             case '.':
0144:                 break;
0145:             case '..':
0146:                 $bn--;
0147:                 if ($bn < 0)    return FALSE;
0148:                 break;
0149:             default:
0150:                 $bb[$bn] = $aa[$i];
0151:                 $bn++;
0152:                 break;
0153:         }
0154:     }
0155:     $ss = '';
0156:     for ($i = 0; $i < $bn$i++)    $ss = $ss . '/' . $bb[$i];
0157: 
0158:     return $regs['scheme'] . '://' . $regs['host'] . $ss;
0159: }

解説:HTMLチェッカー

指定した HTML が文法的に正しいかどうか、W3C が用意する Markup Validation Service を使ってチェックのが、ユーザー関数 htmlChecker である。
バリデーション結果をスクレイピングし、エラーとワーニングの数を返す。

0407: /**
0408:  * リンク先の有無を表示する
0409:  * @param   string $url チェックするURL
0410:  * @param   int エラー数+ワーニング数/FALSE
0411: */
0412: function htmlChecker($url) {
0413:     $url = getURL_htmlChecker($url);
0414:     $str = https($url);
0415:     if ($str == FALSE)  return FALSE;
0416: 
0417:     $error   = preg_match_all('/\<li class\=\"error\"\>/ui', $str);
0418:     $warning = preg_match_all('/\<li class\=\"info warning\"\>/ui', $str);
0419: 
0420:     return $error + $warning;
0421: }

解説:https通信

Markup Validation Service は、https通信、かつ User-Agent を要求することから、組み込み関数  fopen 、 file_get_contents  ではバリデーションを実行できない。
そこで、ユーザー関数 https を用意した。「PHP で住所から緯度経度を求める」で作った [#http:http] 関数の https 対応版である。

0330: /**
0331:  * HTTPS通信を行う
0332:  * @param   string $url "https://" から始まるURL
0333:  * @param   string $method GET,POST,HEAD (省略時はGET)
0334:  * @param   string $headers その他の任意のヘッダ (省略時は"")
0335:  * @param   array  $post POST変数を格納した連想配列("変数名"=>"値") (省略時はNULL)
0336:  * @param   string $cookie Cookie(利用するときは常に$method="POST") (省略時は"")
0337:  * @return  string 取得したコンテンツ/FALSE 取得エラー
0338: */
0339: function https($url$method='GET', $headers='', $post=NULL$cookie='') {
0340:     if ($cookie != '')  $method = 'POST';
0341:     $URL = parse_url($url);
0342: 
0343:     $URL['query'] = isset($URL['query']) ? $URL['query'] : '';       //クエリ
0344:     $URL['port']  = isset($URL['port'])  ? $URL['port']  : 443;       //ポート番号
0345: 
0346:     //リクエストライン
0347:     $request  = $method . ' ' . $URL['path'] . '?' . $URL['query'] . " HTTP/1.1\r\n";
0348: 
0349:     //リクエストヘッダ
0350:     $request .= 'Host: ' . $URL['host'] . "\r\n";
0351:     $request .= 'User-Agent: PHP/' . phpversion() . "\r\n";
0352: 
0353:     //追加ヘッダ
0354:     $request .= $headers;
0355: 
0356:     //POSTの時
0357:     if (strtoupper($method) == 'POST') {
0358:         while (list($name$value) = each($post)) {
0359:             $POST[] = $name . '=' . $value;
0360:         }
0361:         $postdata = implode('&', $POST);
0362:         $request .= "Content-Type: application/x-www-form-urlencoded\r\n";
0363:         $request .= 'Content-Length: ' . strlen($postdata) . "\r\n";
0364:         if ($cookie != '')  $request .= "Cookie: $cookie\r\n";
0365:         $request .= "\r\n";
0366:         $request .= $postdata;
0367:     } else {
0368:         $request .= "\r\n";
0369:     }
0370: 
0371:     //接続
0372:     $fp = fsockopen('ssl://' . $URL['host'], $URL['port']);
0373:     //エラー処理
0374:     if (! $fp)   return FALSE;
0375: 
0376:     //リクエスト送信
0377:     fputs($fp$request);
0378: 
0379:     //応答データ受信
0380:     $response = FALSE;
0381:     $s = trim(fgets($fp));
0382:     if (preg_match('/HTTP\/1\.[0-9]+ 2[0-9]+/i', $s) != 0) {
0383:         $response = '';
0384:         while (! feof($fp)) {
0385:             $s = trim(fgets($fp));
0386:             if ($s == '')  break;
0387:         }
0388:         while (! feof($fp)) {
0389:             $s = fgets($fp);
0390:             $response .= $s;
0391:         }
0392:     }
0393:     fclose($fp);
0394: 
0395:     return $response;
0396: }

参考サイト

(この項おわり)
header