PHPでリンク切れを調べる

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

(2026年8月23日)Pythonサブプログラムによるチェック範囲拡大
(2026年4月26日)Pythonサブプログラム "check404sub.py" 追加, validateHTML - User-Agent変更
(2025年12月28日)PHP8.5対応:curl_closeを使わない

目次

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

PHPでリンク切れを調べる
このプログラムは、URLで指定したネット上のコンテンツに含まれるリンク先(<a>タグや<img>タグなどに書かれているURL)が存在するかどうかをチェックする。使い方は、実行画面下方に表示されているとおり。
存在チェックを実施したくないURLを、除外パターンとして正規表現で複数指定することができる。パターンはPHPの正規表現と同様、スラッシュで囲む /.../ こと。2つ以上のパターンを指定したいときは、改行で区切る。たとえば、"/e-soul/webtech/php02/" で始まるURLを除外したければ、
/https\:\/\/www\.pahoo\.org\/e\-soul\/webtech\/php02\//
と入力する。

サンプル・プログラム

圧縮ファイルの内容
check404.phpサンプル・プログラム本体。
check404sub.pyPythonサブプログラム
pahooInputData.phpデータ入力に関わる関数群。
使い方は「数値入力とバリデーション」「文字入力とバリデーション」などを参照。include_path が通ったディレクトリに配置すること。
pahooScraping.phpスクレイピングS処理に関わるクラス pahooScraping。
スクレイピング処理に関わるクラスの使い方は「PHPで作るRSSビューア」を参照。include_path が通ったディレクトリに配置すること。
check404sub.py 更新履歴
バージョン 更新日 内容
pahooInputData.php 更新履歴
バージョン 更新日 内容
2.0.1 2025/08/11 getParam() bug-fix
2.0.0 2025/08/11 pahooLoadEnv() 追加
1.9.0 2025/07/26 getParam() 引数に$trim追加
1.8.1 2025/03/15 validRegexPattern() debug
1.8.0 2024/11/12 validRegexPattern() 追加
pahooScraping.php 更新履歴
バージョン 更新日 内容
1.2.1 2024/10/31 __construct() 文字化け対策
1.2.0 2024/09/29 getValueFistrXPath() 属性値でない指定に対応
1.1.0 2023/10/15 getValueFistrXPath() 追加
1.0.1 2023/09/29 __construct() bug-fix
1.0.0 2023/09/18 初版

準備:初期値など

check404.php

  59: // 初期値(START) ============================================================
  60: 
  61: // 表示幅(ピクセル)
  62: define('WIDTH', 600);
  63: 
  64: // 偽装ユーザー
  65: define('USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36');
  66: 
  67: // スクレイピング処理に関わるクラス:include_pathが通ったディレクトリに配置
  68: require_once('pahooScraping.php');
  69: 
  70: // デフォルトのスクレイピング対象クエリ
  71: $text = [
  72:     "//a/@href",
  73:     "//img/@src",
  74:     "//link/@href",
  75:     "//script/@src",
  76:     "//meta[@name='og:url']/@content",
  77:     "//meta[@name='og:image']/@content",
  78:     "//meta[@name='twitter:image']/@content",
  79:     "//link[@rel='canonical']/@href",
  80: ];
  81: define('XPATH_LIST', $text);
  82: 
  83: // デフォルトの除外パターン(チェック対象外URL)リスト
  84: define('DEF_EXCLUDE', '');
  85: 
  86: // Pythonサブ・プログラム【変更不可】
  87: define('PYTHON_SUBPROGRAM', 'python check404sub.py ');
  88: 
  89: // 初期値(END) ==============================================================

初期値は、【変更不可】の記載の無いものは自由に変更できる。
後述するユーザー関数の幾つかで、ブラウザからアクセスが来ているように見せかけるために、USER_AGENT の偽装値を設定しておく。

リンク先を検出するのに、XPath式を利用する。XPath式 およびユーザー定義クラス pahooScraping については「PHPでDOMDocumentを使ってスクレイピング」をご覧いただきたい。
下表に示すタグにあるリンク先をデフォルトでチェックするよう、定数 XPATH_LIST に代入してある。過不足があれば、適宜編集してほしい。
XPath式意味
//a/@hrefaタグのhref属性にあるリンク
//img/@srcimgタグのsec属性にあるリンク
//link/@hreflinkタグのhref属性にあるリンク
//script/@srcscriptaタグのsrc属性にあるリンク
//meta[@name='og:url']/@contentmetaタグのname属性がog:urlのものでcontent属性にあるリンク
//meta[@name='og:image']/@contentmetaタグのname属性がog:imageのものでcontent属性にあるリンク
//meta[@name='twitter:image']/@contentmetaタグのname属性がtwitter:imageのものでcontent属性にあるリンク
//link[@rel='canonical']/@hreflinkタグのrel属性がcanonicalのものでhref属性にあるリンク

準備:pahooInputData 関数群

PHPのバージョンや入力データのバリデーションなど、汎用的に使う関数群を収めたファイル "pahooInputData.php" が同梱されているが、include_path が通ったディレクトリに配置してほしい。他のプログラムでも "pahooInputData.php" を利用するが、常に最新のファイルを1つ配置すればよい。

また、各種クラウドサービスに登録したときに取得するアカウント情報、アプリケーションパスワードなどを登録した .pahooEnv ファイルから読み込む関数 pahooLoadEnv を備えている。こちらについては、「各種クラウド連携サービス(WebAPI)の登録方法」をご覧いただきたい。

準備:pahooScraping クラス

pahooScraping.php

  13: class pahooScraping {
  14:     private $dom;           //DOMDocument
  15:     private $xpath;         //DOMXPath
  16:     private $language;      //使用言語(初期値は日本語)
  17:     private $error;         //エラー・メッセージ
  18:     private $errorTable;    //エラー番号 => エラー・メッセージ 一覧
  19: 
  20: /**
  21:  * コンストラクタ
  22:  * @param   string $contents 解析対象テキスト(UTF-8)
  23:  * @return  object /NULL:PHP8未満のため実行できない.
  24: */
  25: function __construct($contents) {
  26:     //PHPのバージョンをチェックする.
  27:     if (! $this->isphp8over()) {
  28:         $this->error = 111;
  29:         throw new Exception($this->getErrorMessage());
  30:         return NULL;
  31:     }
  32:     //プロパティの初期値を設定する.
  33:     try {
  34:         // Unicodeの範囲を指定
  35:         $map = [ 0x80, 0xFFFF, 0, 0xFFFF ];
  36:         // DOMの文字化け対策:UTF-8文字を数値エンティティに変換
  37:         $contents = mb_encode_numericentity($contents, $map, 'UTF-8');
  38:         // DOMDocument クラス
  39:         $this->dom = new DOMDocument();
  40:         libxml_use_internal_errors(TRUE);
  41:         @$this->dom->loadHTML($contents);
  42:         libxml_clear_errors();
  43:         $this->xpath = new DOMXPath($this->dom);
  44:     } catch (Exception $e) {
  45:         $this->error = 121;
  46:         throw new Exception($e->getMessage());
  47:         return FALSE;
  48:     }
  49: 
  50:     //エラー番号 => エラー・メッセージ 一覧
  51:     $this->errorTable = array(
  52:         111 => array('PHP8以上が必要です', 'PHP8 or higher is required'),
  53:         121 => array('DOMエラーです', 'DOM error'),
  54:         999 => array('不明のエラー', 'unknown error'),
  55:     );
  56: }

Webサイトのスクレイピング処理を行うためのクラスが pahooScraping である。同梱のクラス・ファイル "pahooScraping.php" は include_path が通ったディレクトリに配置してほしい。他のプログラムでも pahooScrapingクラス を利用するが、常に最新のクラス・ファイルを1つ配置すればよい。

PHPのクラスについては「PHPでクラスを使ってテキストの読みやすさを調べる」を参照されたい。

準備:Python実行環境

ボット対策が講じられているサイトでは、後述するPHP関数によるアクセスを受け付けない場合がある。そこで、TLSフィンガープリント(JA3など)を偽装できる Pythonライブラリ curl_cffi を使う。残念ながら、現時点で curl_cffi は PHPに移植されていない。そこで、Python実行環境を用意する。
Python実行環境のインストール方法については、「1.2 実行環境と開発環境/各バージョンのサポート期限 - Python入門」をご覧いただきたい。

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

check404.php

 463: /**
 464:  * 指定コンテンツに含まれるリンク先の有無を配列に格納する
 465:  * @param   string $sour      コンテンツURL
 466:  * @param   array  $items     チェック結果を格納する配列
 467:  * @param   string $errmsg    エラーメッセージを格納
 468:  * @param   string $queryList チェック対象のXPath式の配列
 469:  * @param   string $exclude   チェック除外するURLパターン
 470:  *                              (正規表現の配列;省略時はNULL)
 471:  * @param   bool   $usePython  Pythonサブプログラムを使うかどうか(省略時:使わない)
 472:  * @return  bool TRUE:処理成功/FALSE:失敗(引数のエラーなど)
 473: */
 474: function check404($sour, &$items, &$errmsg, $queryList=XPATH_LIST, $excludes=NULL, $usePython=FALSE) {
 475:     $context = getContext($sour);
 476:     $contents = @file_get_contents($sour, FALSE, $context);
 477:     if ($contents == FALSE) {
 478:         $errmsg = $url . ' は存在しない.';
 479:         return FALSE;
 480:     }
 481: 
 482:     // スクレイピング開始
 483:     $pcr = new pahooScraping($contents);
 484:     $index = 0;
 485: 
 486:     // チェック対象のXPath式を1つずつ調べていく
 487:     foreach ($queryList as $query) {
 488:         $imageNode = $pcr->queryXPath($query);
 489:         // XPath式に合致するノードを1つずつ調べていく
 490:         foreach ($imageNode as $node) {
 491:             // URLチェックを行うフラグを立てる
 492:             $flagCheck = TRUE;
 493:             // ノードにあるURLを正規化する
 494:             $url = normalizeURLs((string)$node->nodeValue, $sour);
 495:             // それがURLでなければスキップ
 496:             if ($url == FALSE) {
 497:                 continue;
 498:             }
 499:             // そのURLが除外URL配列に含まれていたら
 500:             // この後の処理をスキップするようフラグを降ろす
 501:             if ($excludes !NULL) {
 502:                 foreach ($excludes as $exclude) {
 503:                     if (preg_match($exclude, $url)) {
 504:                         $flagCheck = FALSE;
 505:                         break;
 506:                     }
 507:                 }
 508:             }
 509:             if ($flagCheck) {
 510:                 // そのURLが存在するかどうか、サイズなどの情報を配列に格納
 511:                 checkLink($url, (int)$node->getLineNo(), $items, $usePython);
 512:                 $index++;
 513:             }
 514:         }
 515:     }
 516:     // スクレイピング終了
 517:     $pcr = NULL;
 518: 
 519:     // 行番号の小さい順に並び替える
 520:     usort($items, function ($a, $b) {
 521:         return $a['ln'> $b['ln'? (+1: (-01);
 522:     });
 523: }

このプログラムの肝となるのはユーザー定義関数 check404 である。
調査するコンテンツのURLを引数 $sour に渡し、与えられた XPath式(複数)を使って、そのコンテンツの中に含まれているリンク先URを取りだし、それがが存在するかどうかを、後述するユーザー関数 checkLink を使って調べる。

まず、調査対象の $sour が存在するかどうかを、組み込み関数  file_get_contents  を使って検証する。このとき、第三引数に後述するユーザー関数 getContext で生成したストリームコンテキストを渡すことで、ブラウザからアクセスしているように見せかける。

次に、ユーザー定義クラス pahooScraping を使ってスクレイピングを開始する。
for文を使い、引数で渡された XPath式 の連想配列を1つずつ調べていく。XPath式 に合致するパターンがあれば、そのノードを配列 $imageNode に代入する。

for文を使い、ノード配列 $imageNode を1つずつ調べていく。
まず、URLチェックを行うフラグ変数 $flagCheck を立てる。
次に、XPath式を使って得られるリンク先は "http(s)://" ではじまらない相対リンクのこともあるので、後述するユーザー関数 normalizeURLs を使って "http(s)://" ではじまるように正規化してから変数 $url に代入する。
$url が引数として渡された除外URL配列 $excludes に合致したら、これ以降の処理をスキップするよう、フラグ変数 $flackCheck を降ろす。

最後に、フラグ変数 $flagCheck が立っていれば、得られたURLが存在するかどうかを、後述するユーザー関数 checkLink を使ってチェックし、結果を連想配列 $items に格納する。

以上の二重ループを抜けたら、結果を格納している連想配列 $items を、行番号の小さい順に並び替える。

解説:ストリームコンテキスト作成

check404.php

 264: /**
 265:  * 偽装用のcontextを返す
 266:  * @param   string $url 対象URL
 267:  * @param   object コンテクスト
 268: */
 269: function getContext($url) {
 270:     // Refererを生成する
 271:     $parsedUrl = parse_url($url);
 272:     $referer = $parsedUrl['scheme'. '://' . $parsedUrl['host'. '/';
 273: 
 274:     // 偽装ヘッダ
 275:     $header = array(
 276:         'Content-Type: text/html; charset=UTF-8',
 277:         'User-Agent: ' . USER_AGENT,
 278:         'Referer: ' . $referer
 279:     );
 280:     $ssl = array(
 281:         'verify_peer' => FALSE,
 282:         'verify_peer_name' => FALSE
 283:     );
 284:     $opts = array(
 285:         'http' => array(
 286:             'method' => 'GET',
 287:             'header' => implode("\r\n", $header),
 288:             'ssl' => $ssl,
 289:             'follow_location' => 1,     // リダイレクトを追跡する
 290:             'max_redirects' => 10,      // 最大リダイレクト回数
 291:         )
 292:     );
 293: 
 294:     return stream_context_create($opts);
 295: }

ブラウザはhttpサーバと通信を行うとき、さまざまなヘッダ情報を付加している。
本プログラムでは、ブラウザがサーバと通信している状態を再現して404エラーが起きるかどうかをチェックするために、これらの情報を組み込み関数  stream_context_create  を使って用意する。用意する情報は、getContext に書かれているとおりだ。
Referer として、対象URLのホスト名を利用する。
これらはアクセス偽装と呼ばれる手法だが、別に悪い意味はないので、念のため。

解説:指定したURLが存在するかどうかを連想配列に格納

check404.php

 387: /**
 388:  * 指定したURLが存在するかどうかを配列に格納する
 389:  * @param   string $url     チェックするURL(正規化済)
 390:  * @param   int    $ln      解析中の行番号
 391:  * @param   array  $items   チェック結果を格納する配列
 392:  * @param   bool   $usePython  Pythonサブプログラムを使うかどうか(省略時:使わない)
 393: */
 394: function checkLink($url, $ln, &$items, $usePython=FALSE) {
 395:     $arr = array();
 396: 
 397:     // URLが $items に含まれていたら行番号以外をコピーして終了
 398:     // ネットへの余計なアクセスを減らすため
 399:     foreach ($items as $item) {
 400:         if ($item['url'] == $url) {
 401:             $arr['ln']   = $ln;
 402:             $arr['url']  = $url;
 403:             $arr['res']  = $item['res'];
 404:             $arr['size'] = $item['size'];
 405:             $items[] = $arr;
 406:             return;
 407:         }
 408:     }
 409: 
 410:     // cURL関数を使って存在チェックする
 411:     if (function_exists('curl_exec')) {
 412:         list($code, $size) = getHttpResponseCodeSize($url);
 413:         if (($code !NULL&& ($code !200)) {
 414:             $arr['res'] = 'なし';
 415:             $size = (-1);
 416:         // サイズ>0 または パス指定
 417:         } else if (($size > 0|| (preg_match('/\/$/ui', $url> 0)) {
 418:             $arr['res'] = 'あり';
 419:         } else {
 420:             $arr['res'] = 'ゼロ';
 421:             $size = 0;
 422:         }
 423: 
 424:     // getContentsSize関数を使って存在チェックする
 425:     } else {
 426:         $size = getContentsSize($url);
 427:         if ($size < 0) {
 428:             $arr['res'] = 'なし';
 429:         } else if ($size == 0) {
 430:             $arr['res'] = 'ゼロ';
 431:         } else {
 432:             $arr['res'] = 'あり';
 433:         }
 434:     }
 435: 
 436:     // Pythonサブプログラムを使って存在チェックする
 437:     if ((($arr['res'] == 'なし'|| ($arr['res'] == 'ゼロ')) && $usePython) {
 438:         // Pythonスクリプト実行
 439:         $cmd = PYTHON_SUBPROGRAM . ' ' . escapeshellarg($url);
 440:         $output = shell_exec($cmd);
 441:         // JSONを配列に変換
 442:         $data = json_decode($output, TRUE);
 443:         // 結果
 444:         $status = $data['status'];
 445:         $size   = $data['size'];
 446:         if (($status !NULL&& ($status !200)) {
 447:             $arr['res'] = 'なし';
 448:             $size = (-1);
 449:         } else if ($size == 0) {
 450:             $arr['res'] = 'ゼロ';
 451:         } else {
 452:             $arr['res'] = 'あり';
 453:         }
 454:     }
 455: 
 456:     // 配列に追加する
 457:     $arr['url']  = $url;        // 対象URL
 458:     $arr['ln']   = $ln;         // 対象URLがある行番号
 459:     $arr['size'] = $size;       // コンテンツ・サイズ(バイト)
 460:     $items[] = $arr;
 461: }

ユーザー定義関数 checkLink は、指定したURL(1つ)が存在するかどうかをチェックし、連想配列 $items に追加格納する。結果出力の都合で、指定したURLが含まれている解析中コンテンツの行番号も引数として渡す。

存在するかどうかをチェックするのに、3つの場合分けをしている。
  1. すでに連想配列 $items に登録されていれば、その内容をコピーする(行番号だけ解析中のものにする)。これは余計なネットワークアクセスを減らすための工夫だ。
  2. PHP処理系にcURL関数が実装されていれば、cURL関数を使って存在チェックする。
  3. それ以外の場合は、ユーザー関数 getContentsSize を使って存在チェックする。
結果は、次の4つの要素に格納する。
  • url‥‥対象URL
  • ln‥‥対象URLがある行番号(表示用)
  • size‥‥コンテンツ・サイズ(バイト)
  • res‥‥あり/なし/ゼロ(表示用)

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

check404.php

 315: /**
 316:  * HTTPレスポンスコード、サイズを取得
 317:  * @param   string $url チェックするURL
 318:  * @return  array(HTTPステータスコード:200 正常/NULL 取得失敗, サイズ)
 319: */
 320: function getHttpResponseCodeSize($url) {
 321:     // Refererを生成する
 322:     $parsedUrl = parse_url($url);
 323:     $referer = $parsedUrl['scheme'. '://' . $parsedUrl['host'. '/';
 324: 
 325:     // ヘッダー設定
 326:     $header = array(
 327:         'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
 328:         'Accept-Language: ja,en-US;q=0.9,en;q=0.8',
 329:         'Cache-Control: max-age=0',
 330:         'User-Agent: ' . USER_AGENT,
 331:         'Referer: ' . $referer,
 332:     );
 333: 
 334:     // cURL関数を使ってコンテンツをオープンする
 335:     $options = array(
 336:         CURLOPT_RETURNTRANSFER => TRUE,     // ホスト名の検証を無効化
 337:         CURLOPT_HEADER         => TRUE,     // HTTPステータスコードを返す
 338:         CURLOPT_HTTPHEADER     => $header,  // ヘッダ情報
 339:         CURLOPT_SSL_VERIFYPEER => FALSE,    // SSL証明書の検証を無効化
 340:         CURLOPT_SSL_VERIFYHOST => FALSE,    // ホスト名の検証を無効化
 341:         CURLOPT_FOLLOWLOCATION => TRUE,     // リダイレクト先に自動アクセス
 342:         CURLOPT_AUTOREFERER    => TRUE,     // リダイレクト先にReferer設定
 343:         CURLOPT_MAXREDIRS      => 10,       // リダイレクトを追跡する最大回数
 344:         CURLOPT_CONNECTTIMEOUT => 120,      // 接続開始までの最大待ち時間
 345:         CURLOPT_TIMEOUT        => 120,      // リクエスト終了までの最大時間
 346:         CURLOPT_ENCODING       => '',       // ヘッダのエンコーディンング
 347:     );
 348:     $ch = curl_init($url);
 349:     curl_setopt_array($ch, $options);
 350:     curl_exec($ch);
 351: 
 352:     // cURLエラーチェック
 353:     if (curl_errno($ch)) {
 354:         // PHP8.5:非推奨関数
 355:         if (PHP_VERSION_ID < 80500) {
 356:             curl_close($ch);
 357:         }
 358:         return array(NULL, NULL);
 359:     }
 360:     $header = curl_getinfo($ch);
 361:     // PHP8.5:非推奨関数
 362:     if (PHP_VERSION_ID < 80500) {
 363:         curl_close($ch);
 364:     }
 365: 
 366:     // コンテンツ・サイズを求める
 367:     $size = $header['download_content_length'];
 368:     // HTTPステータスが200でサイズが求められなかった場合の処置
 369:     if (($header['http_code'] == 200&& ($size < 0)) {
 370:         $size = getContentsSize($url);
 371:         if ($size < 0) {
 372:             $options[CURLOPT_HEADER] = FALSE;
 373:             $ch = curl_init($url);
 374:             curl_setopt_array($ch, $options);
 375:             $res = curl_exec($ch);
 376:             // PHP8.5:非推奨関数
 377:             if (PHP_VERSION_ID < 80500) {
 378:                 curl_close($ch);
 379:             }
 380:             $size = strlen($res);
 381:         }
 382:     }
 383: 
 384:     return array($header['http_code'], $size);
 385: }

PHPにcURL関数が実装されている場合は、ユーザー定義関数 getHttpResponseCodeSize を使って、指定したURLが存在するかどうかをチェックする。戻り値は、HTTPレスポンスコードとコンテンツ・サイズ(バイト)である。

httpヘッダ は、配列 $http に格納しておく。getContext 関数同様、User-AgentReferを偽装する。

cURL関数に設定するオプションは、コメントの通りである。CURLOPT_ENCODING は、コンテンツがgzipで圧縮されている場合に備えての指定である。

アクセスに成功したら、 curl_getinfo  関数で得られる download_content_length をコンテンツ・サイズとして返す。
もし、HTTPステータスコードが正常(200)なのに、この値が取得できなかった場合は、後述するユーザー定義関数 getContentsSize を使ってサイズを取得する。

解説:http上のファイルサイズ取得

check404.php

 297: /**
 298:  * http上のファイルサイズ取得
 299:  * @param   string $url 対象コンテンツ
 300:  * @param   long   ファイルサイズ
 301: */
 302: function getContentsSize($url) {
 303:     $data = '';
 304:     $context = getContext($url);
 305:     $fp = @fopen($url, 'r', FALSE, $context);
 306:     if (! $fp)      return (-1);
 307:     while (! feof($fp)) {
 308:         $data .fread($fp, 1024);
 309:     }
 310:     fclose($fp);
 311: 
 312:     return strlen($data);
 313: }

ユーザー定義関数 getContentsSize は、cURL関数が使えないときにURLの存在チェックを行う。

ユーザー関数 getContext で生成したストリームコンテキストを使って、ブラウザからアクセスしているように見せかけて  fopen  関数を実行し、[fread:phph_function] 関数を使ってコンテンツを読み込み、そのサイズを返す。

解説:URLを正規化する

XPath式 で得られたリンク先は相対アドレス(例:"./index.htm")のこともあるので、ステータスを調べるときに支障が出る。そこで、すべて絶対アドレスに正規化する。そのためのユーザー定義関数が normalizeURLs である。引数として、正規化する対象URLと、読み込んだコンテンツのURL(基準となる絶対アドレス)の2つを渡す。

まず、httpではじまるアドレスであれば絶対アドレスなので、即リターンする。

次に、基準となる絶対アドレス $sour と正規化するURLを結合し、変数 $url に再代入する。このとき、パス分離記号のスラッシュ "/" が二重にならないよう調整している。

この段階では、変数 $url の中に、相対パス指定を意味するドット . または .. が含まれている。続く処理で、スラッシュ "/" を目印にパスを分離し、ドット標記を消し込んでいく。

check404.php

 199: /**
 200:  * URLを正規化する
 201:  * 相対アドレスを絶対アドレスに変換する
 202:  * @param   string $url  正規化するURL
 203:  * @param   string $sour 読み込んだコンテンツのURL(絶対アドレス)
 204:  * @return  string 正規化したURL / FALSE(正規化に失敗;URLではない)
 205: */
 206: function normalizeURLs($url, $sour) {
 207:     // 前後の空白を除く
 208:     $url = trim($url);
 209:     // httpで始まればそのまま返す
 210:     if (preg_match('/^http/', $url!FALSE) {
 211:         return $url;
 212:     }
 213: 
 214:     // 末尾のスラッシュを除く
 215:     $sour = rtrim($sour, '/');
 216: 
 217:     // 基準となる絶対アドレスと正規化するURLを結合する
 218:     if (preg_match('/\:\/\//', $url) == FALSE) {
 219:         $regs = parse_url($sour);
 220:         // URLとして分離できなければFALSE
 221:         if (!isset($regs['scheme']) || !isset($regs['host'])) {
 222:             return FALSE;
 223:         }
 224:         $dirname = isset($regs['path']) ? dirname($regs['path']) : '';
 225:         if (preg_match('/^\//', $url> 0) {
 226:             $url = ltrim($url, '/');        // 冒頭のスラッシュは除く
 227:             $dirname = '';
 228:         }
 229:         $url = $regs['scheme'. '://' . $regs['host'. $dirname . '/' . $url;
 230:     }
 231: 
 232:     // ドット . または .. を置換する
 233:     $regs = parse_url($url);
 234:     // URLとして分離できなければFALSE
 235:     if (!isset($regs['scheme']) || !isset($regs['host'])) {
 236:         return FALSE;
 237:     }
 238:     $aa = preg_split("/[\/\\\]/", $regs['path']);
 239:     $an = count($aa);
 240:     $bb = array();
 241:     $bn = 0;
 242:     for ($i = 1$i < $an$i++) {
 243:         switch ($aa[$i]) {
 244:             case '.':
 245:                 break;
 246:             case '..':
 247:                 $bn--;
 248:                 if ($bn < 0return FALSE;
 249:                 break;
 250:             default:
 251:                 $bb[$bn] = $aa[$i];
 252:                 $bn++;
 253:                 break;
 254:         }
 255:     }
 256:     $ss = '';
 257:     for ($i = 0$i < $bn$i++) {
 258:         $ss = $ss . '/' . $bb[$i];
 259:     }
 260: 
 261:     return $regs['scheme'. '://' . $regs['host'. $ss;
 262: }

解説:HTMLバリーデーション

指定したHTMLが文法的に正しいかどうか、W3Cが用意するクラウドサービス「Markup Validation Service」と連携してチェックする。
Markup Validation Service
URL
https://validator.w3.org/nu/
リクエスト・データ(http) header Content-Type "text/html; charset=UTF-8" User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.101 Safari/537.36" get doc 検査するURL out "json"
レスポンス・データ(json) url 検査したURL messages type info(情報)|warning(警告)|error(エラー) lastLine エラーや警告が発生した行番号 firstColumn エラーや警告が発生した列の開始位置 lastColumn エラーや警告が発生した列の終了位置 message エラーメッセージの内容 extract 問題のある箇所のHTML抜粋 hiliteStart exgtract内の強調開始位置 hiliteLength extract内の強調長さ type info(情報)|warning(警告)|error(エラー) lastLine エラーや警告が発生した行番号 firstColumn エラーや警告が発生した列の開始位置 lastColumn エラーや警告が発生した列の終了位置 message エラーメッセージの内容 extract 問題のある箇所のHTML抜粋 hiliteStart exgtract内の強調開始位置 hiliteLength extract内の強調長さ language 検査したHTMLの言語
これをユーザー定義関数として実装したものが validateHTML である。

check404.php

 525: /**
 526:  * 指定したURLをHTMLバリーデーションする.
 527:  * W3C Markup Validator Web Service APIを利用する.
 528:  * @param   string $url バリデーションするURL
 529:  * @return  array(エラー数,警告数,情報数)/FALSE:APIコール失敗
 530: */
 531: function validateHTML($url) {
 532:     // W3C Markup Validator Web Service API
 533:     $webapi = 'https://validator.w3.org/nu/?';
 534:     // API引数
 535:     $params = array(
 536:         'doc'   => $url,
 537:         'out'   => 'json',
 538:     );
 539:     // HTTPヘッダ
 540:     $headers = array(
 541:         'Content-Type: text/html; charset=UTF-8',
 542:         'User-Agent: Mozilla/5.0 (PHP Validator)',
 543:     );
 544: 
 545:     // W3C Markup Validator Web Service APIを呼び出す.
 546:     $options = array(
 547:         CURLOPT_URL            => $webapi . http_build_query($params),
 548:         CURLOPT_RETURNTRANSFER => TRUE,     // ホスト名の検証を無効化
 549:         CURLOPT_HTTPHEADER     => $headers, // ヘッダ情報
 550:         CURLOPT_SSL_VERIFYPEER => FALSE,    // SSL証明書の検証を無効化
 551:         CURLOPT_SSL_VERIFYHOST => FALSE,    // ホスト名の検証を無効化
 552:         CURLOPT_FOLLOWLOCATION => TRUE,     // リダイレクト先に自動アクセス
 553:         CURLOPT_AUTOREFERER    => TRUE,     // リダイレクト先にReferer設定
 554:         CURLOPT_MAXREDIRS      => 10,       // リダイレクトを追跡する最大回数
 555:         CURLOPT_CONNECTTIMEOUT => 120,      // 接続開始までの最大待ち時間
 556:         CURLOPT_TIMEOUT        => 120,      // リクエスト終了までの最大時間
 557:     );
 558: 
 559:     $ch = curl_init();
 560:     curl_setopt_array($ch, $options);
 561:     $response = curl_exec($ch);
 562:     // PHP8.5:非推奨関数
 563:     if (PHP_VERSION_ID < 80500) {
 564:         curl_close($ch);
 565:     }
 566: 
 567:     // エラーチェック
 568:     if ($response == FALSE) {
 569:         return FALSE;
 570:     }
 571:     // JSONデコード
 572:     $json = json_decode($response);
 573: 
 574:     // エラーチェック
 575:     if (! isset($json->messages)) {
 576:         return FALSE;
 577:     } else if (count($json->messages) == 0) {
 578:         return array(0, 0, 0);
 579:     }
 580: 
 581:     // エラー、警告、情報をカウントする.
 582:     $error = $warning = $info = 0;
 583:     foreach ($json->messages as $message) {
 584:         // エラー
 585:         if (preg_match('/error/ui', (string)$message->type> 0) {
 586:             $error++;
 587:         // 警告
 588:         } else if (preg_match('/warning/ui', (string)$message->type> 0) {
 589:             $warning++;
 590:         // 情報
 591:         } else if (preg_match('/info/ui', (string)$message->type> 0) {
 592:             $info++;
 593:         }
 594:     }
 595: 
 596:     return array($error, $info, $warning);
 597: }

バリデーション結果は、messagesノードに格納される。messagesノードは1つまたは複数あり、その下のtypeノードが "error" ならエラーを、"warning" なら警告を、"info" なら情報を、それぞれカウントする。

解説:Pythonサブプログラム

check404sub.py
import sys
import json
from curl_cffi import requests

# サブルーチン ============================================================== def fetchStatusAndSize(url: str, timeout: int = 10): """指定URLのHTTPステータス・コードとコンテンツ・サイズを返す Args: url: コンテンツのURL timeout: タイムアウト時間(秒)(省略時:10秒) Returns: tuple: HTTPステータス・コード, コンテンツ・サイズ(byte) """ try: # まず HEAD を試す r = requests.head(url, timeout=timeout, allow_redirects=True, impersonate="chrome")
status_code = r.status_code content_length = r.headers.get("Content-Length")
# Content-Length があればそれを使う if content_length is not None: return status_code, int(content_length)
# 無ければ GET にフォールバック r = requests.get(url, timeout=timeout, allow_redirects=True, impersonate="chrome") return r.status_code, len(r.content)
except Exception: return None, None
# メイン・プログラム ======================================================== if __name__ == "__main__": # 引数があればそれを使う if len(sys.argv) > 1: url = sys.argv[1] else: # なければキーボード入力 url = input("URLを入力してください: ").strip()
# 空入力対策 if not url: print(json.dumps({"status": None, "size": None})) sys.exit(0)
status, size = fetchStatusAndSize(url)
result = { "status": status, "size": size }
print(json.dumps(result))
ボット対策を行っているサイトでは、 stream_context_create 関数や cURL関数を使ってもアクセスできない場合がある。そこで、TLSフィンガープリント(JA3など)を偽装できる Pythonライブラリ curl_cffi を使うことにした。
PHP関数では「なし」となってしまう URL に対して、Pythonサブプログラム "check404sub.py" を実行する。

"check404sub.py" は、コマンドラインに URL を与えた場合、その URL にアクセスし、HTTPステータス・コードとコンテンツ・サイズを JSON形式で返す。コマンドラインにURLがないとき(単独で "check404sub.py" を実行することを想定)は、キーボードから URL を入力する。

URLから HTTPステータス・コードとコンテンツ・サイズを求めるのがユーザー定義関数 fetchStatusAndSize である。
まず、request.head を実行し、Content-Length を取得する。引数 impersonate="chrome" を指定することで、ユーザーエージェントだけでなく、通信レベルでブラウザ Chrome を再現する。

解説:メイン・プログラム

メイン・プログラムは、FORMタグに入力されているパラメータを受け取り、上述の check404関数を使ってリンク先のチェックを行い、必要に応じて上述の validateHTML 関数を使ってHTMLバリデーションを行う。

なお、パラメータ取得時に、対象URLがURL文字列であるかどうか、"pahooInputData.php" にある validURL関数を使ってバリデーションする。
また、除外パターンを行分割して配列 $excludes に格納し、各要素に対して正規表現であるかどうかのバリデーションを行う。バリデーションを行うのは、以下に示すユーザー定義関数 validRegexPattern である。

check404.php

 724: // メイン・プログラム ======================================================
 725: 
 726: // パラメータを取得する
 727: $items = array();
 728: $errmsg = '';
 729: $htmlerror = $htmlwarning = $htmlinfo = '?';
 730: 
 731: $flag = getParam('flag', FALSE, '');
 732: $flag = ($flag == '1'? FALSE : TRUE;
 733: $checkhtml = getParam('checkhtml', FALSE, '');
 734: $checkhtml = ($checkhtml == '1'? TRUE : FALSE;
 735: $usePython = getParam('python', FALSE, '');
 736: $usePython = ($usePython == '1'? TRUE : FALSE;
 737: 
 738: // URLのバリデーション
 739: $url = getParam('url', FALSE, '');
 740: if (isButton('exec'&& (validURL($url, $errmsg) == FALSE)) {
 741:     $errmsg = $url . ' はURLではありません';
 742: }
 743: 
 744: // 除外パターンのバリデーション
 745: $excludeList = NULL;
 746: $exclude = getParam('exclude', FALSE, DEF_EXCLUDE);
 747: if (isButton('exec'&& ($exclude !'')) {
 748:     $excludes = explode("\n", $exclude);
 749:     foreach ($excludes as $key=>$val) {
 750:         $val = trim($val);
 751:         if ($val !'') {
 752:             if (validRegexPattern($val)) {
 753:                 $excludeList[$key] = $val;
 754:             } else {
 755:                 $errmsg = $val . ' は正規表現ではありません';
 756:                 break;
 757:             }
 758:         }
 759:     }
 760:     if (count($excludeList) == 0) {
 761:         $excludeList = NULL;
 762:     }
 763: }
 764: 
 765: // 404チェックを実行する
 766: if ($errmsg == '') {
 767:     $errmsg= ($url !''? check404($url, $items, $errmsg, XPATH_LIST, $excludeList, $usePython: '';
 768:     // HTMLバリデーションを行う。
 769:     if (($url !''&& ($errmsg == ''&& $checkhtml) {
 770:         $arr = validateHTML($url);
 771:         if ($arr !FALSE) {
 772:             $htmlerror   = $arr[0];
 773:             $htmlinfo    = $arr[1];
 774:             $htmlwarning = $arr[2];
 775:         }
 776:     }
 777: }
 778: 
 779: // 表示HTMLを作成する
 780: $HtmlBody = makeCommonBody($url, $items, $htmlerror, $htmlwarning, $htmlinfo, $errmsg, $flag, $checkhtml, $exclude, $usePython);
 781: 
 782: // 画面に表示する
 783: echo $HtmlHeader;
 784: echo $HtmlBody;
 785: echo $HtmlFooter;
 786: 
 787: /*

pahooInputData.php

 535: /**
 536:  * 正規表現のバリデーションを行う.
 537:  * @参考URL https://www.pahoo.org/e-soul/webtech/php06/php05-23-01.shtm
 538:  * @param   string $reg  正規表現;スラッシュ /.../ で囲むこと.
 539:  * @param   string $text マッチングさせる文字列;デフォルトでは空文字
 540:  * @return  bool TRUE:正規表現/FALSE:正規表現ではない
 541: */
 542: function validRegexPattern($reg, $text='') {
 543:     $res = @preg_match($reg, $text);
 544:     return ($res === FALSE? FALSE : TRUE;
 545: }

参考サイト

(この項おわり)
header