PHPでリンク切れを調べる

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

(2022年1月10日)コンテンツ・サイズ取得の例外に対応
(2021年6月5日)get_context()追加,USER_AGENT 変更。
(2020年12月19日)HTTPヘッダ偽装を追加。
(2020年12月5日)PHP 8対応。

目次

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

PHPでリンク切れを調べる

サンプル・プログラム

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

 376: /**
 377:  * リンク先の有無を表示する
 378:  * @param   string $url      チェックするURL
 379:  * @param   int    $flag_a   <a>タグのハイパーリンク先をチェックする
 380:  * @param   int    $flag_img <img>タグの画像ソースをチェックする
 381:  * @param   array  $items    チェック結果を格納する配列
 382:  * @param   string $exclude  チェック除外するURLパターン(正規表現;省略可)
 383: */
 384: function check404($url, $flag_a, $flag_img, &$items, $exclude='') {
 385:     $context = get_context();
 386:     $fp = @fopen($url, 'r', FALSE, $context);
 387:     if ($fp == FALSE)   return $url . ' は存在しない.';
 388: 
 389:     $ln = 1;
 390:     while (! feof($fp)) {
 391:         $s = fgets($fp);
 392:         if ($flag_a) {
 393:             $pattern = "/<a.+?href\=\"([^\"]+)\"/i";
 394:             check_link($pattern, 1, $s, $ln, $url, $items, $exclude);
 395:         }
 396:         if ($flag_img) {
 397:             $pattern = "/<img.+?src\=\"([^\"]+)\"/i";
 398:             check_link($pattern, 1, $s, $ln, $url, $items, $exclude);
 399:         }
 400:         $ln++;
 401:     }
 402:     fclose($fp);
 403: 
 404:     return '';
 405: }

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

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

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

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

 324: /**
 325:  * パターンに一致するものがあるかどうかチェックする
 326:  * @param   string $pattern パターン(正規表現)
 327:  * @param   int    $n       パターンに一致する場所(正規表現内の順序)
 328:  * @param   string $str     チェック対象文字列
 329:  * @param   int    $ln      解析中の行番号
 330:  * @param   string $url     解析中のURL
 331:  * @param   array  $items   チェック結果を格納する配列
 332:  * @param   string $exclude チェック除外するURLパターン(正規表現;省略可)
 333: */
 334: function check_link($pattern, $l, $str, $ln, $url, &$items, $exclude='') {
 335:     $n = preg_match_all($pattern, $str, $arr);
 336: 
 337:     for ($i = 0$i < $n$i++) {
 338:         $target = reg_urls($arr[$l][$i], $url);
 339:         //除外パターンのチェック
 340:         if ($exclude !='') {
 341:             if (preg_match('/' . $exclude . '/ui', $target> 0)    continue;
 342:         }
 343:         //ファイル・オープン
 344:         $cfp = @fopen($target, 'r');
 345:         $arr2['ln']  = $ln;
 346:         $arr2['url'] = $target;
 347:         $arr2['res'] = 'あり';
 348: 
 349:         if (function_exists('curl_exec')) {
 350:             list($code, $size) = getHttpResponseCodeSize($target);
 351:             if (($code !NULL&& ($code !200)) {
 352:                 $arr2['res'] = 'なし';
 353:                 $arr2['size'] = (-1);
 354:             //サイズ>0 または パス指定
 355:             } else if (($size > 0|| (preg_match('/\/$/ui', $target> 0)) {
 356:                 $arr2['res'] = 'あり';
 357:                 $arr2['size'] = $size;
 358:             } else {
 359:                 $arr2['res'] = 'ゼロ';
 360:                 $arr2['size'] = 0;
 361:             }
 362:         } else if ($cfp == FALSE) {
 363:             $arr2['res'] = 'なし';
 364:             $arr2['size'] = (-1);
 365:         } else {
 366:             $size = contents_size($target);
 367:             if ($size <0) {
 368:                 $arr2['res'] = 'ゼロ';
 369:                 $arr2['size'] = 0;
 370:             }
 371:         }
 372:         $items[] = $arr2;
 373:     }
 374: }

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

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

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

 272: /**
 273:  * HTTPレスポンスコード、サイズを取得
 274:  * @param   string $url 解析中のURL
 275:  * @return  array(HTTPレスポンスコード:200 正常/NULL 取得失敗, サイズ)
 276: */
 277: function getHttpResponseCodeSize($url) {
 278:     //Cookie格納ファイル
 279:     $temp_cookie = tempnam(sys_get_temp_dir(), 'cookie');
 280: 
 281:     $header = NULL;
 282:     $options = array(
 283:         CURLOPT_RETURNTRANSFER => TRUE,
 284:         CURLOPT_HEADER         => TRUE,
 285:         CURLOPT_FOLLOWLOCATION => TRUE,
 286:         CURLOPT_ENCODING       => '',
 287:         CURLOPT_USERAGENT      => USER_AGENT,
 288:         CURLOPT_SSL_VERIFYPEER => FALSE,
 289:         CURLOPT_SSL_VERIFYHOST => FALSE,
 290:         CURLOPT_AUTOREFERER    => TRUE,
 291:         CURLOPT_CONNECTTIMEOUT => 120,
 292:         CURLOPT_TIMEOUT        => 120,
 293:         CURLOPT_MAXREDIRS      => 10,
 294:     );
 295:     $ch = curl_init($url);
 296:     curl_setopt_array($ch, $options);
 297:     curl_exec($ch);
 298: 
 299:     //cURLエラーチェック
 300:     if (curl_errno($ch)) {
 301:         curl_close($ch);
 302:         return array(NULL, NULL);
 303:     }
 304:     $header = curl_getinfo($ch);
 305:     curl_close($ch);
 306: 
 307:     //コンテンツ・サイズ
 308:     $size = $header['download_content_length'];
 309:     if (($header['http_code'] == 200&& ($size < 0)) {
 310:         $size = contents_size($url);
 311:         if ($size < 0) {
 312:             $options[CURLOPT_HEADER] = FALSE;
 313:             $ch = curl_init($url);
 314:             curl_setopt_array($ch, $options);
 315:             $res = curl_exec($ch);
 316:             curl_close($ch);
 317:             $size = strlen($res);
 318:         }
 319:     }
 320: 
 321:     return array($header['http_code'], $size);
 322: }

ユーザー定義関数 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 を呼び出す。

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

 254: /**
 255:  * http上のファイルサイズ取得
 256:  * @param   string $url 対象コンテンツ
 257:  * @param   long   ファイルサイズ
 258: */
 259: function contents_size($url) {
 260:     $data = '';
 261:     $context = get_context();
 262:     $fp = @fopen($url, 'r', FALSE, $context);
 263:     if (! $fp)      return (-1);
 264:     while (! feof($fp)) {
 265:         $data .fread($fp, 1024);
 266:     }
 267:     fclose($fp);
 268: 
 269:     return strlen($data);
 270: }

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

解説:URLの正規化

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

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

 180: /**
 181:  * URLを正規化する(相対指定を絶対指定に変換する)
 182:  * @param   string $url  正規化するURL
 183:  * @param   string $sour 読み込んだコンテンツのURL
 184:  * @return  string 正規化したURL / FALSE(正規化に失敗)
 185: */
 186: function reg_urls($url, $sour) {
 187:     //httpで始まればスキップ
 188:     if (preg_match('/^http/', $url!FALSE)   return $url;
 189: 
 190:     $sour = rtrim($sour, '/');      //末尾のスラッシュは除く
 191:     //相対指定の場合
 192:     if (preg_match('/\:\/\//', $url) == FALSE) {
 193:         $regs = parse_url($sour);
 194:         $dirname = isset($regs['path']) ? dirname($regs['path']) : '';
 195:         if (preg_match('/^\//', $url> 0) {
 196:             $url = ltrim($url, '/');        //冒頭のスラッシュは除く
 197:             $dirname = '';
 198:         }
 199:         $url = $regs['scheme'. '://' . $regs['host'. $dirname . '/' . $url;
 200:     }
 201:     //相対指定を絶対指定に変換する
 202:     $regs = parse_url($url);
 203:     $aa = preg_split("/[\/\\\]/", $regs['path']);
 204:     $an = count($aa);
 205:     $bb = array();
 206:     $bn = 0;
 207:     for ($i = 1$i < $an$i++) {
 208:         switch ($aa[$i]) {
 209:             case '.':
 210:                 break;
 211:             case '..':
 212:                 $bn--;
 213:                 if ($bn < 0return FALSE;
 214:                 break;
 215:             default:
 216:                 $bb[$bn] = $aa[$i];
 217:                 $bn++;
 218:                 break;
 219:         }
 220:     }
 221:     $ss = '';
 222:     for ($i = 0$i < $bn$i++$ss = $ss . '/' . $bb[$i];
 223: 
 224:     return $regs['scheme'. '://' . $regs['host'. $ss;
 225: }

解説:HTMLチェッカー

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

 407: /**
 408:  * 指定したURLをHTMLバリーデーションする.
 409:  * W3C Markup Validator Web Service APIを利用する.
 410:  * @param   string $url バリデーションするURL
 411:  * @return  array(エラー数,ワーニング数)/FALSE:APIコール失敗
 412: */
 413: function validateHTML($url) {
 414:     //W3C Markup Validator Web Service API
 415:     $webapi = 'https://validator.w3.org/nu/?';
 416:     //API引数
 417:     $params = array(
 418:         'doc'   => $url,
 419:         'out'   => 'json',
 420:     );
 421:     //HTTPヘッダ
 422:     $headers = array(
 423:         'Content-Type: text/html; charset=UTF-8',
 424:         'User-Agent: ' . USER_AGENT,
 425:     );
 426: 
 427:     //W3C Markup Validator Web Service APIを呼び出す.
 428:     $ch = curl_init();
 429:     curl_setopt($ch, CURLOPT_URL, $webapi . http_build_query($params));
 430:     curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
 431:     curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
 432:     curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
 433:     curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
 434:     curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
 435:     curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
 436:     curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
 437: 
 438:     $response = curl_exec($ch);
 439:     curl_close($ch);
 440: 
 441:     //エラーチェック
 442:     if ($response == FALSE)     return FALSE;
 443:     //JSONデコード
 444:     $json = json_decode($response);
 445:     //エラーチェック
 446:     if (! $json->messages)      return FALSE;
 447: 
 448:     //エラーおよびワーニングをカウントする.
 449:     $error = $warning = 0;
 450:     foreach ($json->messages as $message) {
 451:         //エラー
 452:         if (preg_match('/error/ui', (string)$message->type> 0) {
 453:             $error++;
 454:         //ワーニング
 455:         } else if (preg_match('/info/ui', (string)$message->type> 0) {
 456:             $warning++;
 457:         }
 458:     }
 459: 
 460:     return array($error, $warning);
 461: }

参考サイト

(この項おわり)
header