膨大なドメインの更新期限を楽にチェックするプログラムを作りました。

## はじめに

インターネットを利用して様々なサービスを展開されている皆様におかれましては、ネットワーク上のホストを識別する為のドメインの1つや2つ取得している人や団体、組織があると思います。

ですが、管理しているドメインが数十以上、しかもドメインを取得しているサービスが諸々の事情で多岐にわたっているケースなどでは管理が大変です。

インターネットの世界において、ドメインの更新期限の前で人は全てにおいて無力です。
__期限が過ぎてしまったらどんなに元気に動いているサーバもサービスダウンと等価__の状態になります。

もちろんそれぞれのドメイン管理サービスから更新期限が近づいた時にメールでお知らせしてくれるので、通常は問題ないのですが、個別に届いてくるので上記のように横断的に数十のドメインを管理する場合はどうにかして集中管理したくなります。

今回は、小ネタですがそんなクリティカルなドメイン管理を楽にする為のプログラムを作ったのでご紹介しまう。

結論からいうと、[https://github.com/yuya-tajima/check_expiry_domains](https://github.com/yuya-tajima/check_expiry_domains)を使えばドメインの更新期限通知の集中管理ができます。

最初は各サービスでAPIがないかと思ったんですが、どうやらなさそうだったので、プログラムによって管理画面にログインさせて、ドメイン一覧のページをスクレイピングによって取得し、出力し、30日以内に更新期限が迫っているドメインについてはWARNING扱いで出力します。

色々なパターンで検証したんですが、IDとパスワードを失敗の回数を重ねると、そのアクセス元のIPアドレスが拒否されるケースがあったので、ご注意下さい。

力技ですが、定期処理させて今のところ毎日正常に動いてますw
あと、もう一手間念のためにユーザーエージェントを偽装しても良いかなとは思ってます。

その他不具合、問題、報告なんでも、プルリクなどでご連絡下さい。

github上にあげているものと同じですが、最後におまけで使い方とソースコードをのせておきます。

## USAGE
```bash:
$ export VALUE_DOMAIN_USER=hoge
$ export VALUE_DOMAIN_PASS=foo
$ php check_expiry_domains.php

# 出力例
WARNING: The expiry date is approaching.

Domain service is value-domain
xxxxxx.jp will expire in 28 days. expiry date is 2018-02-28.

INFO: There are over 30 days until the expiry date.

Domain service is value-domain
yyyyyy.jp 2018-11-30
zzzzzz.jp 2018-08-31
... 省略
```

## ソースコード
```php:check_expiry_domains.php
<?php
/**
 * 更新期限の近づいているドメインをお知らせする。
 *
 * インタプリタ: PHP 5.6以上
 *
 * 引数: なし
 *
 * 前提条件:
 *
 *   バリュードメインをチェックする場合
 *   呼び出すプロセスの環境変数VALUE_DOMAIN_USERにバリュードメインのユーザー名を指定しておく
 *   呼び出すプロセスの環環境変数VALUE_DOMAIN_PASSにバリュードメインのパスワード名を指定しておく
 *
 *   gonbei.jpをチェックする場合
 *   呼び出すプロセスの環境変数GONBEI_USERにgonbei.jpのユーザー名を指定しておく
 *   呼び出すプロセスの環環境変数GONBEI_PASSにgonbei.jpのパスワード名を指定しておく
 *
 *   お名前.comをチェックする場合
 *   呼び出すプロセスの環境変数ONAMAE_USERにお名前.comのユーザー名を指定しておく
 *   呼び出すプロセスの環環境変数ONAMAE_PASSにお名前.comのパスワード名を指定しておく
 *
 *   さくらインターネットをチェックする場合
 *   呼び出すプロセスの環境変数SAKURA_USERにさくらインターネットのユーザー名を指定しておく
 *   呼び出すプロセスの環環境変数SAKURA_PASSにさくらインターネットのパスワード名を指定しておく
 *
 *   ※ ログインに二段階認証を適用している場合は利用できません。
 *   ※ あまりにもログインに失敗するとIPで拒否されるケースを確認。
 *
 * 結果:
 *   ドメインの一覧と更新期限を出力する
 *
 */
function exe_check_expiry_domains() {
	$alert_interval  = 30;
	$today_obj       = new DateTime(date('Y-m-d'));
	$max_retry_count = 3;
	$check_urls = [];
	if ( getenv('VALUE_DOMAIN_USER') && getenv('VALUE_DOMAIN_PASS') ) {
		$check_urls['value-domain'] = [
			'login'      => 'https://www.value-domain.com/login.php'
			,'page'      => 'https://www.value-domain.com/extdom.php'
			,'post_data' => [
				'username'  => getenv('VALUE_DOMAIN_USER')
				,'password' => getenv('VALUE_DOMAIN_PASS')
				,'action'   => 'login2'
			]
			,'type'      => 'dom'
		];
	}
	if ( getenv('GONBEI_USER') && getenv('GONBEI_PASS') ) {
		$check_urls['gonbei'] = [
			'login' => 'https://ias.il24.net/register/login.cgi'
			,'page' => 'https://ias.il24.net/mymenu/new.cgi'
			,'post_data' => [
				'LOGINID'   => getenv('GONBEI_USER')
				,'PASSWORD' => getenv('GONBEI_PASS')
				,'TEMPLATE' => ''
				,'login'    => ''
			]
			,'type'      => 'dom'
		];
	}
	if ( getenv('ONAMAE_USER') && getenv('ONAMAE_PASS') ) {
		$check_urls['onamae.com'] = [
			'login' => 'https://www.onamae.com/domain/navi/domain.html'
			,'page' => 'https://www.onamae.com/domain/navi/domain'
			,'post_data' => [
				'username'   => getenv('ONAMAE_USER')
				,'password'  => getenv('ONAMAE_PASS')
			]
			,'type'      => 'dom'
		];
	}
	if ( getenv('SAKURA_USER') && getenv('SAKURA_PASS') ) {
		$check_urls['sakura-internet'] = [
			'login' => 'https://secure.sakura.ad.jp/auth/login'
			,'page' => 'https://secure.sakura.ad.jp/menu/service/?mode=SD1010&ac=init'
			,'post_data' => [
				'memberLogin[membercd]'  => getenv('SAKURA_USER')
				,'memberLogin[password]' => getenv('SAKURA_PASS')
			]
			,'type'      => 'dom'
		];
	}
	$soon_expires = [];
	$late_expires = [];
	foreach ( $check_urls as $service => $urls ) {
		$expires = [];
		$retry_count = 0;
		$is_login    = false;
		while ( ! $is_login && ( ++$retry_count <= $max_retry_count ) ) {
			$ch = curl_init();
			$cookie_path = '/tmp/' . $service;
			curl_setopt( $ch, CURLOPT_URL,            $urls['login'] );
			curl_setopt( $ch, CURLOPT_POSTFIELDS,     http_build_query( $urls['post_data'] ) );
			curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
			curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
			curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
			curl_setopt( $ch, CURLOPT_COOKIEJAR, $cookie_path );
			$response = curl_exec( $ch );
			if ( $errno  = curl_errno( $ch ) ) {
				$error_message = curl_strerror( $errno );
				fwrite( STDERR, sprintf( 'curl error: error code %d. message %s. url %s.', $errno, $error_message, $url ) . PHP_EOL );
				curl_close( $ch );
			} else {
				$httpcode = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
				curl_close( $ch );
				if ( file_exists( $cookie_path ) ) {
					$is_login = true;
				} else {
					if ( $httpcode >= 400 ) {
						fwrite( STDERR, sprintf( 'ERROR: %s Login failed. HTTP STATUS CODE is %d', $service, $httpcode ) . PHP_EOL );
						break;
					} else {
						fwrite( STDERR, sprintf( 'WARNING: %s Login failed. retry count %d...', $service, $retry_count ) . PHP_EOL );
						sleep(5);
					}
				}
			}
		}
		if ( ! $is_login ) {
			fwrite( STDERR, sprintf( 'ERROR: %s Login failed.', $service ) . PHP_EOL );
			fwrite( STDERR, PHP_EOL );
			continue;
		} else {
			fwrite( STDERR, sprintf( 'INFO: Maybe %s Login Success.', $service ) . PHP_EOL );
			fwrite( STDERR, PHP_EOL );
		}
		$ch = curl_init();
		curl_setopt( $ch, CURLOPT_URL,            $urls['page'] );
		curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false );
		curl_setopt( $ch, CURLOPT_COOKIEFILE,     $cookie_path );
		$response = curl_exec( $ch );
		if( $errno = curl_errno( $ch ) ) {
			$error_message = curl_strerror( $errno );
			fwrite( STDERR, sprintf( 'curl error: error code %d. message %s. url %s.', $errno, $error_message, $url ) . PHP_EOL );
		} else {
			$httpcode = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
			if ( $response ) {
				if ( $urls['type'] === 'dom' ) {
					$doc = new DOMDocument();
					libxml_use_internal_errors( true );
					$doc->loadHTML( $response );
					libxml_clear_errors();
					$xpath = new DOMXPath($doc);
					switch ( $service ) {
						case 'value-domain':
							$nodes = $xpath->query('//table/tr[position()>1]');
							foreach ( $nodes as $node  ) {
								$node_value  = preg_replace( '#[ \t\r\n]+#', ' ', $node->nodeValue );
								$arr         = explode(' ', $node_value );
								$arr         = array_values(array_filter($arr));
								$domain      = $arr[0];
								$expire_date = $arr[1];
								$expires[]   = [$domain, $expire_date];
							}
							break;
						case 'gonbei':
							$nodes = $xpath->query('//table/tr/td/table/tr/td/table/tr/td/table/tr/td/table/tr/td/table/tr');
							foreach ( $nodes as $node ) {
								$node_value = preg_replace( '#[ \t\r\n]+#', ' ', $node->nodeValue );
								$node_value = trim($node_value);
								if ( preg_match('#ドメイン取得サービス.*契約中.*#', $node_value, $m )) {
									$arr = explode(' ', $m[0] );
									$arr = array_values(array_filter($arr));
									if ( count($arr) <= 5 ) {
										array_splice( $arr, 0, -3 );
										$domain = $arr[0];
										$expire_date = str_replace( ['迄', '/'], ['', '-'], $arr[2] );
										$expires[] = [$domain, $expire_date];
									}
								}
							}
							break;
						case 'onamae.com':
							$nodes = $xpath->query('//table/tr[position()>1]');
							foreach ( $nodes as $node ) {
								$node_value = preg_replace( '#[ \t\r\n]+#', ' ', $node->nodeValue );
								$node_value = trim($node_value);
								$arr = explode(' ', $node_value );
								$arr = array_values(array_filter($arr));
								$domain      = $arr[0];
								$expire_date = str_replace( ['/'], ['-'], $arr[1] );
								$expires[]   = [$domain, $expire_date];
							}
							break;
						case 'sakura-internet':
							$nodes = $xpath->query('//table[@class="frame"]/tr[position()>1]');
							foreach ( $nodes as $node ) {
								$node_value = preg_replace( '#[ \t\r\n]+#', ' ', $node->nodeValue );
								$node_value = trim($node_value);
								$arr = explode(' ', $node_value );
								$arr = array_values(array_filter($arr));
								$domain = preg_replace( '#[0-9]+\z#', '', $arr[0] );
								$expire_date = str_replace( ['年', '月', '日'], ['-', '-', ''], $arr[1] );
								$expires[]   = [$domain, $expire_date];
							}
							break;
					}
				}
				foreach ( $expires as $e ) {
					$expire_date_obj = new DateTime( $e[1] );
					$interval        = $expire_date_obj->diff( $today_obj );
					$expire_days     = $interval->format('%a');
					if ( $alert_interval > (int) $expire_days ) {
						$soon_expires[$service][] = [ $e[0], $e[1], $expire_days ];
					} else {
						$late_expires[$service][] = [ $e[0], $e[1], $expire_days ];
					}
				}
			}
		}
		curl_close( $ch );
		if ( file_exists( $cookie_path ) ) {
			unlink( $cookie_path );
		}
	}
	if ( $soon_expires ) {
		fwrite( STDOUT, sprintf( 'WARNING: The expiry date is approaching.' ) . PHP_EOL );
		fwrite( STDOUT, PHP_EOL );
		foreach ( $soon_expires as $service => $_soon_expires ) {
			fwrite( STDOUT, sprintf( 'Domain service is %s', $service ) . PHP_EOL );
			foreach ( $_soon_expires as $e ) {
				fwrite( STDOUT, sprintf( '%s will expire in %d days. expiry date is %s.', $e[0], $e[2], $e[1] ) . PHP_EOL );
			}
		}
		fwrite( STDOUT, PHP_EOL );
	}
	if ( $late_expires ) {
		fwrite( STDOUT, sprintf( 'INFO: There are over %d days until the expiry date.', $alert_interval ) . PHP_EOL );
		fwrite( STDOUT, PHP_EOL );
		foreach ( $late_expires as $service => $_late_expires ) {
			fwrite( STDOUT, sprintf( 'Domain service is %s', $service ) . PHP_EOL );
			foreach ( $_late_expires as $e ) {
				fwrite( STDOUT, sprintf( '%s %s', $e[0], $e[1] ) . PHP_EOL );
			}
			fwrite( STDOUT, PHP_EOL );
		}
	} else {
		fwrite( STDOUT, sprintf( 'NOTICE: All of the access might have been denied.' ) . PHP_EOL );
	}
}
exe_check_expiry_domains();
```

## 所感

単純にマークアップ言語から情報を抽出するスクレイピングにおいては、XPathの構文を解析できるライブラリが使えるプログラミング言語なら何でもできるなという所感。